diff --git a/README.md b/README.md index 1bfc1a1..9d834b3 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ Basic "show an SVG on both platforms" is now a solved problem (Coil 3, Kamel, La | **Cloth sheet** — a 2-D Verlet sheet that drapes over & collides with capsule "poles": tarp / curtain / banner / flag (`OGCloth` / `OGClothCollider`) | ✅ | ❌ | ❌ | | **Photo → an editable vector SCENE** — gradients, texture, glow, scatter + make-it-live motion day→night (`OGSceneSpec`) | ✅ | ❌ | ❌ | | **Clickable-joints mesh rig** — bind joints & bend a dense mesh live (`OGRig`) | ✅ | ❌ | ❌ | +| **Cut-out puppet** — a jointed raster figure (base + movers) that breathes, leans & watches a look target (`OGPuppet` / `OGPuppetView`) | ✅ | ❌ | ❌ | +| **Fire** — a data-defined flickering flame (candle / campfire / torch / inferno) (`OGFlame` / `OGFlameView`) | ✅ | ❌ | ❌ | +| **Look-behind-glass lens** — a draggable magnifier / minifier / coloured glass over any content (`OGGlass`) | ✅ | ❌ | ❌ | | Bundled image + audio + video suite | ✅ | ❌ | ❌ | If all you need is a static SVG loaded from the network, a general image loader is the simpler choice. Reach for KMPMedia when you need media to **move, respond, compose, or change at runtime**. @@ -208,6 +211,7 @@ Here's the full rundown of this wave (📷 live camera is the one effect not sho - **🖼️ Living AI scenes** *(v1.38.0 – v1.40.0)* — the `OGSceneSpec` "photo → vector scene" format grew up. **v1.38.0** adds per-shape **gradient fills** (OKLab-resampled, no banding) plus a scene background + colour grade; **v1.39.0** adds richer stills — a procedural **texture** (fur / grass / grain), an additive **glow** halo, and a seeded **scatter** that stamps one shape into a whole field (trees / tufts); **v1.40.0** makes it **live** — a region declares its own looping motion (translate / scale / rotate / fade) and the scene swings a colour grade + sky over time (day → dusk → night), all pure and `t`-addressable so the compositor can scrub + export it. Backward-compatible, pure codec, 60fps, same code on Android & iOS. - **🦁 Clickable-joints rig** *(v1.41.0)* — a deform **rig** for a dense scene mesh: `OGRig` binds a handful of joints to the mesh once (linear-blend skinning, partition-of-unity weights), then a drag warps the whole mesh around the joint, re-skinned every frame — the one case the rasterise-once / cache path can't cover. ~1.3 ms to skin 20k vertices; `deformInto` is zero-allocation, `nearestJoint` is the touch hit-test, and a released joint springs home with `OGSpring`. Pure maths, zero dependency, 60fps, frame-identical on Android & iOS. - **🎞️ OGStage — one JSON, a whole movie** *(v1.42.0 – v1.45.0)* — the capstone that unifies every pack onto one serializable timeline: `OGStage` stacks `solid` / `scene` / `particles` / `slash` / `lightning` / `speedlines` / styled-**text** layers, each transformed by the compositor's keyframe tracks and graded by a look. One `OGStageView` plays it at 60fps and scrubs it; `OGStage.exportGif()` / `exportMp4()` render it off-screen with **every layer kind baked in** (the iOS README clip was rendered headless on the simulator through that very path). A language model authors the whole movie from one line via `OGStages.stagePrompt()` — the codec tolerates real model output (bare-number tracks, case-insensitive enums, markdown fences), and three model-written movies (STORM / HANAMI / RUSH) ship in the demo and are pinned as regression tests. **v1.44.0 makes it interactive:** any layer can carry an `onTap` trigger — tap it and it springs to life (`BOUNCE` / `SHAKE` / `SPIN` via the dynamics `OGSpring`, or `SEEK` / `PLAY_PAUSE`), the Rive-style interactivity the CMP players led on, as plain model-authorable data. **v1.45.0 closes the describe→movie loop:** `OGStageAuthor` turns a plain sentence into a playable stage, and `OGOfflineStageAuthor` does it with **no model, no key and no network** (a deterministic director that reads the words for mood / subject) — so the loop always works; wire your own model (cloud or on-device) into the same loop via `OGStageModel` / `OGStageAuthoring.resilient(...)` for richer movies. Thin orchestration, no new per-frame cost, same code on Android & iOS. See [**docs/STAGE.md**](docs/STAGE.md) and [**docs/AI_AUTHORING.md**](docs/AI_AUTHORING.md). +- **🔥🔍🎭 Fire, a glass lens & cut-out puppets** *(v1.48.0)* — three new primitives plus more wind. **`OGFlame`** (`com.solidkey.painpoints.fx`) is a data-defined body of fire — upward-licking tongues that wave with `OGFxNoise` and flicker over time, a hot-core → cool-tip gradient, four presets (`candle` / `campfire` / `torch` / `inferno`) and a prevailing `wind` you can drive from a scene's breeze; pure maths, no shader, frame-identical on both platforms. **`OGGlass`** (`com.solidkey.painpoints.glass`) lays a round, **draggable** look-behind-glass lens over any content — a magnifier (`zoom > 1`), a minifier (`zoom < 1`) or a coloured-glass tint — recording the content once into a `GraphicsLayer` and redrawing it scaled + clipped, its body pointer-transparent so you keep tapping underneath (an accessibility win as much as an effect). **`OGPuppet`** (`com.solidkey.painpoints.puppet`) is a data-defined **cut-out raster puppet** — a still base plus jointed movers that breathe, lean and *watch* a look target (with `headMirror` / `headLookInvert` for a one-sided vs symmetric head), authored by the Layer Cutter tool. Plus **`OGSnowflake`** is now a **recursive-fractal** crystal (a `depth` knob grows real dendrites), and **particles gained the cloth wind model** (`wind` + seeded `windAmplitude` / `windFrequency` / `windSeed` gusts) so cloth and particles blow as one breeze. OGStage gains two layer kinds to match — a wind-blown **`cloth`** strip and a frame-swappable raster **`image`** cutout. Zero dependency, 60fps, same code on Android & iOS. See [**docs/FLAME.md**](docs/FLAME.md) · [**docs/GLASS.md**](docs/GLASS.md) · [**docs/PUPPET.md**](docs/PUPPET.md) · [**docs/PARTICLES.md**](docs/PARTICLES.md) · [**docs/STAGE.md**](docs/STAGE.md). *(📷 Live camera is the one effect without a GIF here — it needs a real device's camera (there's nothing to film on a headless emulator), so run the [demo](https://github.com/SolidKeyAB/kmpmedia-demo/releases) on a device to see it live.)* diff --git a/docs/COMPETITIVE_LANDSCAPE.md b/docs/COMPETITIVE_LANDSCAPE.md new file mode 100644 index 0000000..f1bd8ae --- /dev/null +++ b/docs/COMPETITIVE_LANDSCAPE.md @@ -0,0 +1,86 @@ +# KMPMedia vs. the Field — Competitive Comparison (2025/2026) + +> Research snapshot, Oct 2026. KMPMedia = `se.solidkey:kmpmedia-lib`: a zero-extra-dependency, +> pure-Compose (no custom shaders, no Skia-direct), data-defined, LLM-authorable KMP graphics/animation +> toolkit (Android + iOS via Compose Multiplatform). Below are the competitors a developer would +> realistically evaluate instead, with current versions / licenses / platform facts. + +## 1. Capability matrix + +Legend: ✅ first-class · ⚠️ partial / possible-with-work / caveat · ❌ not offered + +| Capability | KMPMedia | Rive (+Rive-CMP) | Compottie | Lottie (Airbnb) | Skiko / Skia-direct | Haze | Konfetti / ConfettiKit | Spine | DragonBones | Compose built-in | Korge | +|---|---|---|---|---|---|---|---|---|---|---|---| +| KMP-native (iOS+Android, pure Kotlin) | ✅ | ⚠️ community wrapper | ✅ pure-Kotlin | ❌ Android-only | ✅ (is the engine) | ✅ | ⚠️ ConfettiKit ✅ / Konfetti Android | ⚠️ no CMP | ⚠️ no CMP | ⚠️ Android; CMP partial | ✅ engine | +| Skeletal / cut-out puppet rig | ✅ raster puppet | ✅ bones+meshes | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ best-in-class | ✅ | ❌ | ⚠️ plugins | +| Vector keyframe anim (AE/JSON) | ⚠️ stage timeline | ✅ own format | ✅ Lottie | ✅ Lottie | ⚠️ DIY | ❌ | ❌ | ⚠️ own | ⚠️ own | ⚠️ DIY | ⚠️ | +| Interactive state machines | ⚠️ tap triggers | ✅ signature | ❌ | ❌ | ❌ | ❌ | ❌ | ⚠️ events | ❌ | ⚠️ manual | ✅ | +| Particles | ✅ data-defined | ⚠️ in-editor | ❌ | ❌ | ⚠️ DIY | ❌ | ✅ confetti only | ❌ | ❌ | ⚠️ DIY | ✅ | +| Blur / glassmorphism | ❌ no true blur | ❌ | ❌ | ❌ | ✅ shader | ✅ specialty | ❌ | ❌ | ❌ | ⚠️ Android | ⚠️ | +| Custom shaders (SkSL/AGSL) | ❌ by design | ⚠️ own renderer | ❌ | ❌ | ✅ full | ✅ | ❌ | ❌ | ❌ | ⚠️ GL | ⚠️ | +| Action/anime FX (slash/lightning/speed-lines) | ✅ unique | ⚠️ hand-animate | ⚠️ | ⚠️ | ⚠️ DIY | ❌ | ❌ | ⚠️ | ⚠️ | ❌ | ⚠️ | +| Color grade / blend modes | ✅ ColorMatrix | ⚠️ in-editor | ⚠️ | ⚠️ | ✅ | ⚠️ tint | ❌ | ❌ | ❌ | ⚠️ | ⚠️ | +| Audio-reactive (FFT→visuals) | ✅ unique | ❌ | ❌ | ❌ | ⚠️ DIY | ❌ | ❌ | ❌ | ❌ | ⚠️ DIY | ⚠️ | +| Gesture interactivity (grab/pinch/twist + hit-test) | ✅ | ✅ pointer→SM | ⚠️ | ⚠️ | ⚠️ DIY | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ manual | ✅ | +| GIF/MP4 off-screen export | ✅ built-in | ⚠️ editor | ❌ | ❌ | ⚠️ DIY | ❌ | ❌ | ⚠️ editor | ⚠️ editor | ❌ | ⚠️ | +| LLM / data-authorable (JSON) | ✅ core thesis | ⚠️ .riv binary | ✅ consumes JSON | ✅ consumes JSON | ❌ | ❌ | ⚠️ config | ❌ binary | ❌ binary | ❌ | ❌ | +| Needs external design tool | ✅ none (Layer Cutter optional) | ❌ Editor | ❌ After Effects | ❌ After Effects | ✅ none | ✅ none | ✅ none | ❌ Spine | ❌ tool | ✅ none | ⚠️ plugin | +| License | **MIT** | MIT runtime (Editor closed) | MIT | Apache-2.0 | Apache-2.0 | Apache-2.0 | Apache-2.0 / MIT | commercial ($69+) | MIT (tool abandoned) | Apache-2.0 | Apache-2.0 | + +## 2. Per-competitor profiles + +**Rive (+ Rive-CMP).** Interactive state-machine 2D animation with its own vector editor (bones, meshes, IK, pointer-driven state machines). Runtimes/renderer/format MIT; the **Editor is closed-source** (free tier generous; OpenRive is an MIT community editor). Official runtimes: android/ios/web/C++ — **no official Compose Multiplatform runtime**; `dev.muazkadan:rive-cmp` (community, ~v0.2.1 Dec 2025) bridges the native runtimes. Beats us on the **visual state-machine editor + designer-driven interactivity**. · + +**Lottie (Airbnb) / lottie-compose.** After Effects → Bodymovin JSON vector playback. Apache-2.0, ~6.7.1 (Oct 2025), **Android-only — Airbnb do not support KMP/CMP**. Massive designer ecosystem (LottieFiles). + +**Compottie.** `io.github.alexzhirkevich:compottie`, **MIT**, ~2.3.3 (2026). A **pure-Kotlin Lottie renderer** (no native bridge) for CMP — Android/iOS/JVM/macOS/Web. Strongest KMP-native vector-animation competitor, but confined to the Lottie format + needs AE to author. + +**Skiko / Skia-direct.** The engine Compose MP renders on (Metal on iOS). Gives raw **RuntimeShader/SkSL** (AGSL on Android), path ops, true blur/bloom — everything we deliberately forgo. Apache-2.0. The floor you drop to for real shaders. + +**Haze (Chris Banes).** Specialty **background blur / glassmorphism** for CMP (Android/iOS/macOS/Desktop/Web). Apache-2.0, **2.0 (Sep 2026)** — split `hazeBlur`/`hazeGlass`, hardware-accelerated. Fills our missing blur/glass. + +**Konfetti / ConfettiKit.** Konfetti (`nl.dionsegijn`, Apache-2.0, ~2.0.5) = classic **Android** confetti. **ConfettiKit** (`io.github.vinceglb`, ~0.8.0 Feb 2026) = KMP/CMP port. Confetti only — a narrow subset of our particle pack. + +**Spine (Esoteric Software).** Industry-standard **2D skeletal animation** (bones, meshes, IK) + commercial editor (Essential $69 / Pro $299); runtime license requires users to own Spine to redistribute (updated Apr 2025). Broad engine runtimes, **no CMP runtime**. Gold standard for rigging — far deeper than our raster puppet, but paid + tool-bound. + +**DragonBones.** Free, **MIT** 2D skeletal tool; desktop editor effectively **abandoned**, runtimes live on GitHub, no CMP runtime. Free puppet alternative to Spine but stagnant. + +**Telegram rlottie.** Standalone **C++** Lottie renderer (Samsung/Telegram). Mixed licensing (LGPL-2.1 / MIT-with-bundled). Fast, but C++/native, no Kotlin/CMP API. + +**Jetpack Compose built-in animation.** `animate*AsState`, `Animatable`, `Transition`, `AnimatedVisibility` — Apache-2.0, the baseline. Great for property/UI tweening; **no particles, FX, puppets, color-grade, export, or authoring format**. + +**Honorable mentions.** Orbital (skydoves, shared-element transitions, CMP), compose-shimmer (`com.valentinilk.shimmer`, ~1.4.0) — narrow single-effect libs, little overlap. **Korge** (~6.0.0, May 2025, Apache-2.0) — full KMP **game engine**; an alternative only if you're building a game, not UI animation. + +## 3. KMPMedia's genuine differentiators + +1. **Data / LLM-authorable by design** — every pack is a JSON spec a model can emit (describe→movie, stage docs). No competitor's *authoring* story is LLM-first; Compottie only *consumes* model-friendly JSON, can't generate its own feature set from prose. +2. **Breadth in one pure-Kotlin KMP artifact** — the field is fragmented (Haze=blur, ConfettiKit=confetti, Compottie=vector, Rive/Spine=rigs, Skiko=shaders). We bundle styles, particles, FX, looks, motion, dynamics, audio-reactive, gesture, camera, stage+export, puppet, fog, light/shadow, skin behind one dependency. +3. **No external tool, no native/shader dependency** — zero extra deps, no Editor subscription (Rive/Spine), no NDK/C++ bridge (rlottie, Rive-CMP), no AE license. Layer Cutter is a free web tool, not a paywall. +4. **Cross-platform pixel-identical + 60fps fixed-capacity sims** — staying in pure Compose avoids the platform-shader divergence Rive-CMP (per-platform native runtimes) and Haze (platform render effects) inherently carry. +5. **Audio-reactive vectors** — essentially unique among Compose/KMP UI libraries. + +## 4. Honest gaps + +- **No visual editor / designer ecosystem** — Rive's state-machine editor, Spine's rigging tool, LottieFiles' marketplace are each a moat we don't match. Decisive for designer-driven teams. +- **No true blur/bloom/custom shaders** — the no-shader rule means Haze (glass/blur) and Skiko (SkSL) beat us for glassmorphism / gaussian bloom / arbitrary pixel FX. +- **Puppet is raster cut-out, not true skeletal** — Spine/DragonBones/Rive offer bones + mesh deform + IK; ours is jointed PNG movers (lighter, less expressive). +- **Not the AE/Lottie standard** — for importing existing motion-design assets, Compottie/Lottie win outright (huge existing content library). +- **Interactivity is tap-triggers, not a full state machine** — Rive wins on branching interactive logic. + +## 5. When to reach for which + +- Designer owns the animation + branching interactivity → **Rive**. +- Importing After Effects motion design into KMP → **Compottie** (Lottie if Android-only). +- Just background blur / frosted glass → **Haze**. +- Just confetti → **ConfettiKit** (KMP) / **Konfetti** (Android). +- Deep 2D character rigging → **Spine** (paid, best) / **DragonBones** (free, abandoned). +- Custom shaders / true bloom / bespoke pixel FX → **Skiko/SkSL** (+ Haze for blur). +- Shipping a game, not UI → **Korge**. +- Simple property/UI transitions → **Compose built-in**. +- One KMP dependency doing FX + particles + motion + puppet + stage/export, authored from JSON / by an LLM, pixel-identical on iOS+Android, no editor or shaders → **KMPMedia** — the only combination the rest of the field can't assemble from a single library. + +## Note / to verify +- **KMPMedia's own license** — **MIT** (© 2025 Özge Güner / SolidKey AB), per the repo `LICENSE`. So KMPMedia is the *only* all-in-one here that's both MIT-licensed AND KMP-native with no editor/shaders — Spine is paid, Rive's editor is closed, and the rest are single-purpose. +- **rlottie licensing** is genuinely mixed across forks (LGPL-2.1 vs MIT-with-bundled) — verify per fork. +- **Rive CMP** — only the community Rive-CMP wrapper exists as of these sources; Rive has signaled interest in a first-party CMP runtime — recheck. +- Context: Compose Multiplatform for iOS went **Stable in 1.8.0 (May 2025)**, underpinning every "KMP-native on iOS" claim. diff --git a/docs/FLAME.md b/docs/FLAME.md new file mode 100644 index 0000000..086b34a --- /dev/null +++ b/docs/FLAME.md @@ -0,0 +1,120 @@ +# Fire — flickering flame (`OGFlame`) + +A body of living, upward-licking **fire**, described as plain data. It is the fire sibling of +[`OGFog`](../library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFogSpec.kt) (smoke) and the +particle `EMBERS` preset (sparks) in the `com.solidkey.painpoints.fx` family: a designer or a language +model authors a `.flame` pack and the library brings it to life — a candle, a campfire, a torch, an +inferno — no code and no rebuild. + +- **Package:** `com.solidkey.painpoints.fx` +- **Spec + pure geometry:** `OGFlameSpec`, `flameTongues` +- **View:** `OGFlameView` +- **Codec + presets:** `OGFlame` + +## How it stays 60fps (the perf gate) + +No fluid sim, no per-pixel shader (the library floors runtime shaders out). A fire is a handful of +**tongues** — tapered flame shapes rooted along a baseline — whose centrelines **wave** with fractal +[`OGFxNoise`] noise and whose heights **flicker** over time. Each tongue is one GPU-filled path with a +vertical hot → cool gradient; the noise is sampled a few times per tongue, never per pixel. It is pure +and deterministic from `seed`, so it is frame-identical on Android & iOS and bakes into an export +byte-for-byte, exactly like the rest of the FX layer. + +## Coordinates + +Everything is normalized `0..1` of the view: `baseY` is where the flames are rooted (`1` = the bottom +edge), `height` / `width` / `sway` are fractions of the view. `y` grows downward (screen convention), +so a tongue rises from `baseY` up toward smaller `y`. + +## Quick start — a ready-made preset + +```kotlin +import com.solidkey.painpoints.fx.OGFlameView +import com.solidkey.painpoints.fx.OGFlame + +// A warm hearth fire at the bottom of the box. Transparent between and above the tongues. +OGFlameView(OGFlame.CAMPFIRE, Modifier.fillMaxSize()) +``` + +Built-in presets (on `OGFlame`): **`CANDLE`** (one slim calm tongue), **`CAMPFIRE`** (a spread of +lively orange tongues), **`TORCH`** (fewer, taller tongues with a steady lean) and **`INFERNO`** (many +tall, ragged, fast-flickering tongues) — or `OGFlame.preset("torch")` by name. For a candle, use the +one-tongue `CANDLE` preset in a narrow box. + +## The view + +```kotlin +@Composable +fun OGFlameView( + spec: OGFlameSpec, + modifier: Modifier = Modifier, + playing: Boolean = true, + windOverride: Float? = null, // bend the whole fire with a scene's live breeze +) +``` + +Set `playing = false` to freeze it. Pass a live `windOverride` to bend the whole fire with a scene's +breeze (overrides `OGFlameSpec.wind` when non-null) — drive it from the same breeze that pushes your +snow / leaves so the fire reacts to the weather. Overlay it in a `Box`. + +## The spec + +```kotlin +import com.solidkey.painpoints.fx.OGFlameSpec + +val torch = OGFlameSpec( + coreColor = "#FFE08A", tipColor = "#FF4312", + tongues = 4, height = 0.78f, width = 0.13f, + sway = 0.05f, wind = 0.08f, flicker = 0.28f, +) +OGFlameView(torch, Modifier.fillMaxSize()) +``` + +| field | default | meaning | +|---|---|---| +| `name` | `null` | optional label | +| `coreColor` | `"#FFD24D"` | the hot colour at the **base** of each tongue (any SVG colour string) | +| `tipColor` | `"#FF4A1C"` | the cooler colour at the **tip** of each tongue | +| `tongues` | `9` | number of flame tongues across the base; more = wider / fuller. Clamped `1..60` | +| `height` | `0.62f` | average tongue height as a fraction of the view | +| `heightJitter` | `0.45f` | per-tongue height variation (`0..1`): `0` = all equal, higher = raggeder crown | +| `width` | `0.16f` | base width of a single tongue (fraction of the view); tongues overlap into one mass | +| `sway` | `0.06f` | how far a tongue's tip swings sideways as it licks (fraction of the view) | +| `wind` | `0f` | a **prevailing wind** that bends the whole flame sideways (`+` = right, `-` = left); grows with height so the base stays put. Clamped `-1..1` | +| `swaySpeed` | `1.1f` | how fast the sideways licking evolves (cycles/second-ish) | +| `flicker` | `0.3f` | height-flicker amount (`0..1`): the fast up-down pulsing of the crown | +| `flickerSpeed` | `2.4f` | how fast the height flicker pulses | +| `octaves` | `3` | fractal-noise octaves for the waving edges. Clamped `1..8` | +| `opacity` | `0.92f` | overall opacity ceiling (`0..1`) | +| `baseY` | `1f` | where the fire is rooted, `0..1` of the view height (`1` = the bottom edge) | +| `seed` | `0` | seed for the deterministic tongue layout, wave and flicker | + +`spec.sanitized()` clamps every field into its sane range, so lenient model / designer input never +breaks a frame. + +## From JSON / from a model + +```kotlin +import com.solidkey.painpoints.fx.OGFlame + +val spec = OGFlame.decodeSpec("""{"name":"campfire","coreColor":"#FFD24D","tipColor":"#FF3B10","tongues":10}""") + +val prompt = OGFlame.flamePrompt("a single calm candle flame bending gently in a draught") +val fromModel = OGFlame.decodeSpecOrNull(myLlm.complete(prompt)) ?: OGFlame.CANDLE +``` + +`OGFlame.encode(spec)` goes the other way; the codec is lenient (code fences / stray prose survive) +and `decodeSpecOrNull` returns `null` instead of throwing — no network, no AI SDK. + +## Driving the geometry yourself + +`flameTongues(spec, timeSec)` is the pure, deterministic geometry — each tongue's `spine` centreline +(base → flickering tip), base `halfWidth` and peak `alpha` — shared by the view and any off-screen +baker, so an export matches the preview. Build a tapered path around each spine and fill it with the +core → tip gradient (the one shared per-frame function is `drawOGFlameFrame`). + +## Honest limits + +- One `core → tip` vertical gradient per tongue (no multi-stop ramp). +- A stylised flame shape, not a volumetric / lit fire — no smoke plume above (pair it with `OGFog`), + no embers (pair it with the `EMBERS` particle preset). diff --git a/docs/GLASS.md b/docs/GLASS.md new file mode 100644 index 0000000..c7dff59 --- /dev/null +++ b/docs/GLASS.md @@ -0,0 +1,110 @@ +# Glass — a look-behind-glass lens (`OGGlass`) + +A round, **draggable glass lens** laid over any content: a **magnifier** (`zoom > 1`), a **minifier** +(`zoom < 1`) and/or **coloured glass** (`tint`). Like the rest of the library's packs it is plain, +serializable data a designer or a language model can author — decode + presets on [`OGGlass`], draw +with [`OGGlass`] (the composable). + +It is as much an **accessibility win** as an effect: drop it over a board, a photo or a word and older +eyes get a movable magnifier; flip `zoom` under `1` for a fit-more minifier, or `tint` it for a +colour-filter viewing glass. + +- **Package:** `com.solidkey.painpoints.glass` +- **Spec:** `OGGlassSpec` +- **Composable:** `OGGlass(spec, …) { content }` +- **Codec + presets:** `OGGlass` + +## How it works (60fps, no shader) + +The content under the lens is recorded **once** into a `GraphicsLayer` (the same trick as +`Modifier.ogGlow`); the lens then re-draws that layer **scaled about its centre**, clipped to a +circle, so whatever sits beneath appears magnified (or shrunk). A translucent `tint`, a soft rim and a +specular highlight sell the "piece of glass" look. No per-pixel work and no runtime shader (the library +floors those out), so it's cheap and cross-platform. + +## Quick start — a ready-made preset + +```kotlin +import com.solidkey.painpoints.glass.OGGlass + +// Wrap whatever should be magnifiable; the lens starts in the centre. +OGGlass(OGGlass.MAGNIFIER, Modifier.fillMaxSize(), onRemove = { showLens = false }) { + YourContent() +} +``` + +Built-in presets (on `OGGlass`): **`MAGNIFIER`**, **`READER`**, **`MINIFIER`**, **`AMBER`**, **`ROSE`** +— or `OGGlass.preset("reader")` by name. + +## The composable + +`OGGlass` is the **outermost wrapper** around whatever should be magnifiable. The lens is drawn **on +top** of the content but its body is **pointer-transparent**, so you keep interacting with the content +underneath (place a word, tap a cell) while the glass is parked over an area. You move it only by +dragging its **grip handle** (the four-arrow glyph on the lower-right of the rim); the optional **✕** +(upper-right, wired to `onRemove`) dismisses it. `enabled = false` hides the glass entirely. + +```kotlin +@Composable +fun OGGlass( + spec: OGGlassSpec, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onRemove: (() -> Unit)? = null, + content: @Composable () -> Unit, +) +``` + +The lens is clamped to stay fully on screen, and starts centred until the first drag. + +## The spec + +```kotlin +import com.solidkey.painpoints.glass.OGGlassSpec + +val amber = OGGlassSpec( + zoom = 1.8f, // magnify 1.8× + radius = 0.24f, // lens radius = 24% of the shorter side + tint = "#FFB300", // warm amber glass + tintStrength = 0.35f, + rimColor = "#FFE08A", + highlight = 0.3f, +) +``` + +| field | default | meaning | +|---|---|---| +| `name` | `null` | optional label | +| `zoom` | `2f` | magnification under the glass: `> 1` magnifies, `< 1` minifies, `1` = a plain (tinted) pane. Clamped `0.2..6` | +| `radius` | `0.22f` | lens radius as a fraction of the **shorter side** of the view. Clamped `0.03..1` | +| `tint` | `"#00000000"` | glass tint, any SVG colour string; fully transparent = a clear magnifier | +| `tintStrength` | `0.25f` | how strongly `tint` is laid over the magnified content (`0..1`) | +| `rimColor` | `"#FFFFFF"` | the glass edge / frame colour | +| `rimWidth` | `0.05f` | rim thickness as a fraction of the lens radius (`0..0.5`) | +| `rimAlpha` | `0.8f` | rim opacity (`0..1`) | +| `highlight` | `0.3f` | specular sheen strength across the top-left of the glass (`0..1`, `0` = none) | + +`spec.sanitized()` clamps every field into its sane range, so lenient model / designer input never +breaks a frame. + +## From JSON / from a model + +```kotlin +import com.solidkey.painpoints.glass.OGGlass + +// A designer- or model-authored pack (tolerant of code fences / stray prose): +val spec = OGGlass.decodeSpec("""{"name":"magnifier","zoom":2.2,"radius":0.22,"highlight":0.3}""") + +// Hand a model the exact contract, then decode its reply: +val prompt = OGGlass.glassPrompt("a warm amber reading magnifier that cuts glare") +val fromModel = OGGlass.decodeSpecOrNull(myLlm.complete(prompt)) ?: OGGlass.MAGNIFIER +``` + +`OGGlass.encode(spec)` goes the other way; `decodeSpecOrNull` returns `null` instead of throwing on +malformed input (the same lenient JSON as the rest of the AI layer — no network, no AI SDK). + +## Honest limits + +- One circular lens; no barrel / fisheye distortion inside the glass (a plain uniform scale). +- The content is captured into one `GraphicsLayer`, so the lens magnifies composed graphics, shapes + and `Canvas` — not a live camera / video surface (the same limit the glow and soft masks have). diff --git a/docs/MOTION.md b/docs/MOTION.md index 8e4a455..edbb021 100644 --- a/docs/MOTION.md +++ b/docs/MOTION.md @@ -176,6 +176,10 @@ scarf.step(dt, anchorX = neckX, anchorY = neckY) // pin the head at the nec // then draw a tapered ribbon through scarf.point(0 .. count-1) ``` +The wind model (prevailing `wind` + seeded turbulent `windAmplitude` / `windFrequency` / `windSeed` +gusts) is **shared with the [particle system](./PARTICLES.md)**, so cloth and particles blow alike — +drive both from one breeze value and a scarf flutters while the snow sweeps the same way. + It advances on a **fixed internal substep** (position Verlet is only stable + deterministic at a constant `dt`), so it is frame-identical on Android & iOS and **exact for fixed-step GIF/MP4 export**. Unlike `OGSpring` it is a **live stepper, not closed-form** — there is no `valueAt(t)`; to *scrub* it, diff --git a/docs/PARAMETRIC_SHAPES.md b/docs/PARAMETRIC_SHAPES.md index 550507a..0ec3405 100644 --- a/docs/PARAMETRIC_SHAPES.md +++ b/docs/PARAMETRIC_SHAPES.md @@ -77,6 +77,46 @@ val fromModel = OGParametrics.decodeOrNull(myLlm.complete(prompt)) An unknown `kind` yields an empty outline (nothing shown) rather than throwing. +## Snowflakes — a recursive-fractal crystal generator (`OGSnowflake`) + +`OGSnowflake` is the line-figure cousin of the closed parametric outlines: a deterministic `seed` → a +unique, n-fold **symmetric ice crystal** as plain line **segments** (`OGSnowflakeSegment(a, b)` in a +unit space centred at `(0,0)`). Unlike the filled parametric shapes, a snowflake is a **stroked line +figure**, so the caller turns the segments into a `Path` and strokes it (no two seeds alike). + +```kotlin +import com.solidkey.painpoints.shape.OGSnowflake + +val segments = OGSnowflake.generate(seed = 42) // arms = 6, branches = 3, depth = 2 by default + +// Turn them into a stroked Path, scaled + translated to place the crystal: +val path = Path().apply { + for (s in segments) { + moveTo(cx + s.a.x * r, cy + s.a.y * r) + lineTo(cx + s.b.x * r, cy + s.b.y * r) + } +} +``` + +How it is built: one **arm** is grown along +Y — a central spine plus a few side-twigs — then +mirrored left ↔ right for bilateral symmetry, and that whole arm is copied `arms` times around the +centre (`6` = a real snowflake's hexagonal symmetry). Each twig is a tiny **recursive fractal**: at +`depth > 0` every twig sprouts its own smaller side-twigs, and those sprout again, the way a real +stellar-dendrite crystal grows. + +```kotlin +fun generate( + seed: Int, + arms: Int = 6, // radial symmetry; clamped 3..12 (6 is natural) + branches: Int = 3, // primary side-twigs per arm; clamped 0..6 + depth: Int = 2, // fractal recursion; clamped 0..4 +): List +``` + +`depth` is the new knob: `0` = the old plain-prong look (straight prongs), `2` (the default) reads as a +convincing dendrite, capped at `4`. It is pure maths with a seeded xorshift — no Compose, zero +dependency — so a given seed yields the same crystal on Android, iOS, preview and export. + ## Where this sits on the roadmap Part of the new **Bet — Generative & living content**, with [particles](./PARTICLES.md): describing diff --git a/docs/PARTICLES.md b/docs/PARTICLES.md index a87945e..c8f9e69 100644 --- a/docs/PARTICLES.md +++ b/docs/PARTICLES.md @@ -65,6 +65,37 @@ SVG colour string (`#RGB` / `#RRGGBB` / `#AARRGGBB` / `rgb()` / a name), parsed parser the SVG renderer uses. Size and colour interpolate from `start*` to `end*` over each particle's life. +## Wind — a living breeze + +Beyond the dead-constant `gravityX`/`gravityY` push, an emitter can carry a **prevailing wind** plus +**turbulent gusts**, so a drift feels like weather instead of a straight line. This is the **same wind +model** as the cloth strip ([`OGClothStrip`](./MOTION.md)), so cloth and particles blow alike — drive +both from one breeze value and the whole scene reads as one wind (snow sweeps while hair streams and a +flame leans the same way). + +```kotlin +val leaves = OGParticleSpec( + emissionRate = 18f, + angleDeg = 90f, speed = 0.08f, gravityY = 0.04f, // drift down + wind = 0.12f, // a steady breeze blowing right + windAmplitude = 0.3f, // peak turbulent gust on top of the steady wind + windFrequency = 0.7f, // how fast the gusts wander + windSeed = 3, // a different-but-repeatable gust pattern + shape = "leaf", +) +``` + +- **`wind`** (`0f`) — a steady horizontal acceleration (normalized units/s², `+` blows right) that + gusts ride on top of. `0` = calm (the default keeps old output byte-identical). +- **`windAmplitude`** (`0f`) — peak **turbulent gust** acceleration layered on top of `wind`, sampled + per-particle from a fractal-noise field, so gusts swell and fade organically and no two particles + lurch in lockstep (a light vertical flutter makes leaves tumble). `0` = steady wind only. +- **`windFrequency`** (`0.7f`) — how fast the gusts wander; higher = choppier, flickier gusts. +- **`windSeed`** (`0`) — the gust pattern; change it for a different-but-repeatable breeze. + +When all wind fields are off the integrator skips the noise work entirely, so a windless emitter is +byte-identical to the pre-wind output. + ## From JSON / from a model ```kotlin @@ -99,8 +130,9 @@ system.reset() // clear + re-arm the opening burst ## Shapes -`shape` is `"circle"` (default), `"square"`, `"triangle"` or `"star"`. For richer silhouettes, pair -particles with a [parametric shape](./PARAMETRIC_SHAPES.md). +`shape` is `"circle"` (default), `"square"`, `"triangle"`, `"star"`, `"petal"` (a leaf) or +`"teardrop"` (a droplet). For richer silhouettes, pair particles with a +[parametric shape](./PARAMETRIC_SHAPES.md). ## Honest limits diff --git a/docs/PUPPET.md b/docs/PUPPET.md new file mode 100644 index 0000000..25b5eb4 --- /dev/null +++ b/docs/PUPPET.md @@ -0,0 +1,168 @@ +# Cut-out puppet (`OGPuppet`) + +A **data-defined cut-out puppet**: a hand-drawn figure cut into transparent part-layers (a still +**base** plus jointed **movers**) and reassembled as a live raster puppet — each mover rotating about +the joint it was pinned at, breathing, leaning and *watching* a look target. It is the raster sibling +of the library's generative data packs ([particles](./PARTICLES.md), [parametric shapes](./PARAMETRIC_SHAPES.md)): +a designer (or the Layer Cutter tool) authors a `.puppet` pack of joints + motion, and the lib brings +it to life — no per-app rigging code. + +- **Package:** `com.solidkey.painpoints.puppet` +- **Spec:** `OGPuppetSpec` → `OGPuppetPart`, plus the transient `OGPuppetPose` +- **Pure pose math:** `OGPuppetRig` (no Compose — deterministic, testable) +- **View:** `OGPuppetView` +- **Codec + prompt:** `OGPuppet` + +Unlike a vector scene, a cut-out puppet is **real drawn pixels** (bundled PNG / WebP sprites), so a +hand-drawn character part lives on screen as itself. The pure transform math (`OGPuppetRig`) is split +out so the same `tSec` + `look` yields the same pose on Android and iOS. + +## Coordinates + +Every part sprite shares the base's frame (each cut part baked / placed on the same canvas, so a mover +needs only its joint). `pivotX` / `pivotY` are the joint as a `0..1` fraction of that frame; +`tuckX` / `tuckY` and `gaze` are fractions of the frame width / height. A **look** value is `-1..1` +horizontally (`-1` = hard left, `+1` = hard right, `0` = centre, `null` = a gentle idle glance). + +## Quick start + +```kotlin +import com.solidkey.painpoints.puppet.OGPuppet +import com.solidkey.painpoints.puppet.OGPuppetView + +// A puppet pack (authored by the Layer Cutter tool or by hand). The self-loading overload resolves +// each sprite by resource name via the platform image loader. +val spec = OGPuppet.decode(myPuppetJson) + +OGPuppetView( + spec, + modifier = Modifier.aspectRatio(spec.aspect), // match the base frame so pivots line up + look = watchTarget, // -1..1, or null for an idle glance +) +``` + +Give the box the base frame's `aspect` so the frame isn't letterboxed and every mover's pivot fraction +lines up with the drawn pixels. + +## The spec + +`OGPuppetSpec` is the whole figure: the still `base` sprite, its `parts` (movers, back → front), the +frame `aspect`, and the feel of its idle life. + +```kotlin +import com.solidkey.painpoints.puppet.OGPuppetSpec +import com.solidkey.painpoints.puppet.OGPuppetPart + +val bee = OGPuppetSpec( + base = "bee_body", + aspect = 1.2f, + grounded = false, // hovers in place (a flying figure) + headMirror = false, // symmetric forward face: tilts toward the look, no flip + parts = listOf( + OGPuppetPart(res = "bee_head", head = true), + OGPuppetPart(res = "bee_wing_l", pivotX = 0.4f, pivotY = 0.35f, swingDeg = 14f, speed = 3f), + OGPuppetPart(res = "bee_wing_r", pivotX = 0.6f, pivotY = 0.35f, swingDeg = 14f, speed = 3f, phase = 3.14f), + ), +) +``` + +### `OGPuppetSpec` + +| field | default | meaning | +|---|---|---| +| `base` | — | resource name of the still base sprite | +| `parts` | `[]` | the movers, back → front | +| `aspect` | `1f` | the base frame's width / height | +| `grounded` | `false` | `true` = stands / sits and bends about its feet (never lifts); `false` = hovers in place (a slow vertical float + airborne tilt) | +| `breatheAmp` / `breatheSpeed` | `0.02f` / `0.5f` | a gentle whole-figure scale pulse | +| `leanAmp` | `0.06f` | whole-figure horizontal lean toward the look target (fraction of width) | +| `lookTurnDeg` | `12f` | head-turn tilt toward the look target (degrees) for the `head` part | +| `headCenterX` / `headCenterY` | `0.5f` / `0.5f` | the head's own centre (frame fraction) — it mirror-flips about this to turn in place | +| `headMirror` | `true` | whether a head part mirror-**flips** to turn (a profile / one-sided face reads as turning when flipped); `false` for a symmetric forward face — it just tilts, no flip | +| `headLookInvert` | `false` | invert the head's look **direction** (tilt + mirror); set `true` if the drawn head turns *away* from the look target instead of toward it | +| `bendDeg` / `bendLookDeg` | `4f` / `5f` | grounded idle bend amplitude + how much more it leans toward the look | +| `hoverAmp` / `tiltDeg` | `0.05f` / `1.5f` | flying hover: vertical float (fraction of height) + airborne tilt sway (degrees) | +| `name` | `null` | optional label | + +### `OGPuppetPart` + +| field | default | meaning | +|---|---|---| +| `res` | — | resource name of this part's transparent sprite | +| `pivotX` / `pivotY` | `0.5f` / `0.5f` | the joint this mover rotates about, `0..1` of the shared frame | +| `swingDeg` | `0f` | idle rotation amplitude (degrees) about the pivot — a faint life-swing (`0` = still) | +| `speed` / `phase` | `1f` / `0f` | idle swing rate + phase, so sibling parts don't move in lockstep | +| `tuckX` / `tuckY` | `0f` / `0f` | a static offset of this part within the frame (fraction of width / height) | +| `gaze` | `0f` | this part **watches** the look target: it slides toward it by `gaze·look` (e.g. coal eyes / a mouth with no head to turn) | +| `head` | `false` | this part is the **head**: it turns toward the look target (a tilt + mirror-flip about `headCenterX` / `headCenterY`) instead of a plain idle swing. At most one part should set this | + +`headMirror` and `headLookInvert` solve the two gotchas of a cut-out head: a **symmetric** forward face +(a bee) should tilt but **not** flip (`headMirror = false`), and when the drawn head faces the "wrong" +way so it appears to turn *away* from the look, flip the direction with `headLookInvert = true`. + +### Host beats — `OGPuppetPose` + +A transient whole-figure pose laid **on top** of the idle life — the host folds its own game beats (a +score pop, a tap-spotlight, a reaction) into one: `OGPuppetPose(dy, scale, rot)` (`dy` a fraction of +height, `scale` a multiplier, `rot` degrees). Identity = `OGPuppetPose.Idle`. + +## The view + +Two overloads: + +```kotlin +// Self-loading: resolves base + part sprites by resource name (the pure-lib path, no sprite swaps). +@Composable +fun OGPuppetView( + spec: OGPuppetSpec, + modifier: Modifier = Modifier, + look: Float? = null, + pose: OGPuppetPose = OGPuppetPose.Idle, + playing: Boolean = true, +) + +// Painter overload: you pass the already-decoded base + part painters (and an optional headPainter to +// swap the head's sprite, e.g. an expression frame), keeping control of loading / sprite-swapping. +@Composable +fun OGPuppetView( + base: Painter?, + parts: List, + spec: OGPuppetSpec, + modifier: Modifier = Modifier, + look: Float? = null, + pose: OGPuppetPose = OGPuppetPose.Idle, + headPainter: Painter? = null, + playing: Boolean = true, +) +``` + +Grounded puppets pivot about their feet and never lift; flying ones float in place. Set +`playing = false` to freeze the rig. + +## From JSON / from a model + +```kotlin +import com.solidkey.painpoints.puppet.OGPuppet + +val spec = OGPuppet.decode(myPuppetJson) // throws on malformed +val safe = OGPuppet.decodeOrNull(myPuppetJson) // null instead + +val prompt = OGPuppet.puppetPrompt("a flying bee with two beating wings and a head that watches") +``` + +Same lenient JSON config as the rest of the data layers (code fences / stray prose survive); +`OGPuppet.encode(spec)` serialises a pack back. The pack names the base + part sprites by resource — +the **host app bundles** them (Android `res/drawable`, iOS bundle), resolved by the shared image loader. + +## The pure rig — `OGPuppetRig` + +All pose math lives in `OGPuppetRig` with no Compose: `effectiveLook`, `figureFloat`, `figureBreathe`, +`figureLean`, `figureRotation`, `partAngle`, `partScaleX`, `partOffsetX` / `partOffsetY`. Same inputs → +identical output on every platform, so it is directly unit-testable and the view is a thin renderer. + +## Honest limits + +- Each part is a **flat raster** rotated about one joint — not a mesh deform (for that, see `OGRig`). +- Movers rotate about a single pivot (no multi-bone chains); `OGPuppetPose` is one whole-figure beat. +- A puppet's sprites are host-bundled assets, so unlike the vector packs a blind describe → movie model + can't author one from scratch — but a tool (the Layer Cutter) or an app assembling its own art can. diff --git a/docs/STAGE.md b/docs/STAGE.md index f899925..1c75900 100644 --- a/docs/STAGE.md +++ b/docs/STAGE.md @@ -52,6 +52,8 @@ an `OGBlendMode`, and gated by a `[startMs, endMs]` window. | `lightning` | a forked lightning bolt | [SLASH.md](SLASH.md) | | `speedlines` | manga speed / impact lines | [SLASH.md](SLASH.md) | | `text` | **styled vector text** — a title / caption / lower-third | [STYLES.md](STYLES.md) | +| `cloth` | a **wind-blown cloth / hair strip** — a scarf, cape edge, banner or lock of hair | [MOTION.md](MOTION.md) | +| `image` | a **raster cutout** — real drawn pixels (a bundled PNG / WebP), optionally frame-swapped | — | ### The text layer @@ -78,6 +80,67 @@ OGStages.text( The live player and the off-screen exporter draw the text through one shared per-frame function, so an exported title matches the preview — the same single-code-path discipline the action-FX layers use. +### The cloth layer + +`OGStageContent.Cloth` wraps the motion family's 1-D Verlet [`OGClothSpec`](MOTION.md) (gravity + +prevailing wind + turbulent gusts) as a first-class stage layer: a scarf, cape edge, banner or a lock +of **long hair** that hangs and *flutters*. Its head pins at (`anchorX`, `anchorY`) inside the layer +box and streams out on the breeze. Pair it with a particle layer blowing the same direction and the +whole scene reads as one wind — snow sweeps, hair streams, a dress ripples, together. + +```kotlin +import com.solidkey.painpoints.motion.OGCloths + +OGStages.cloth( + id = "hair", + cloth = OGCloths.preset("hair")!!.copy(wind = -0.5f), // reuse a preset, set the wind + anchorX = 0.5f, anchorY = 0.12f, // the pinned head, 0..1 of the layer box + color = "#3A2A1C", + width = 0.05f, // ribbon half-thickness, fraction of the box's shorter side + taper = true, // narrow to a point at the free end (a hair lock / pennant) +) +``` + +The cloth builder's parameters: `cloth` (the fabric feel), `anchorX` / `anchorY` (the pinned head), +`tailX` / `tailY` (the far-end pin, used only when `OGClothSpec.pinTail` — a banner strung taut), +`color`, `width`, `taper`, plus the usual layer transform tracks / `look` / `blend` / `onTap`. It is +deterministic: the strip re-simulates from `0` to the layer-local time at a fixed internal substep, so +a live frame and an exported frame are byte-identical on both platforms (one shared per-frame draw, +`drawOGStageClothFrame`). The anchor is **fixed within the box** (the layer transform moves it), so the +strip responds to *wind*, not to the character's own acceleration — inertial drag is out of scope. + +### The image layer + +`OGStageContent.Image` is a **raster cutout** — real drawn pixels (a bundled PNG / WebP), not a +redrawn vector — so a hand-drawn character part lives on the stage as itself. One or more +`OGImageFrame`s are shown in order on the layer's local clock (looping), so a cutout **animates by +frame-swap** (a blink, a mouth shape, a wave) while the layer's own transform tracks move it and an +`onTap` makes it poke-reactive — a paper-doll / Spine-style puppet, authored as data. + +```kotlin +// A still cutout: +OGStages.imageStill(id = "hero", resource = "hero_body", widthFrac = 0.6f, heightFrac = 0.9f) + +// A frame-swap blink (hold the resting frame long, the transients briefly): +OGStages.image( + id = "face", + frames = listOf( + OGImageFrame("eyes_open", holdMs = 3000L), + OGImageFrame("eyes_half", holdMs = 40L), + OGImageFrame("eyes_shut", holdMs = 90L), + ), + widthFrac = 1f, heightFrac = 1f, +) +``` + +Each `OGImageFrame(resource, holdMs = 120L)` names a resource the **host app bundles** (Android +`res/drawable`, iOS bundle), resolved by the shared image loader — so unlike the other content kinds a +blind describe → movie model can't author this one, but an app assembling a puppet from its own assets +can. The image is centred in the layer box and scaled to **fit** within `widthFrac × heightFrac` of it +(aspect preserved), so you place and size it with the layer transform, not by pre-scaling the art. A +single-frame cutout is a still; `imageStill` is the one-frame shorthand. `frameIndexAt(timeMs)` on the +content gives the frame shown at a layer-local time (looping over the total hold span). + ## Describe → playable movie The whole document is model-authorable. `OGStages.stagePrompt(instruction)` returns the exact contract diff --git a/gradle.properties b/gradle.properties index e21a588..154940e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,7 +28,7 @@ android.useAndroidX=true android.nonTransitiveRClass=true -LIBRARY_VERSION_FALLBACK=1.43.0 +LIBRARY_VERSION_FALLBACK=1.48.1 GROUP=se.solidkey POM_ARTIFACT_ID=kmpmedia-lib POM_NAME=KMP Media Library diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControl.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControl.kt index 7752fff..dd11371 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControl.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControl.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -14,6 +15,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameMillis import androidx.compose.ui.Alignment +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache @@ -23,6 +27,7 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.solidkey.painpoints.ai.toSpecColor @@ -88,6 +93,10 @@ private val DefaultControlLabelStyle: OGStyle = * @param garnish a custom overlay slot drawn above the fill and below the label (wins over the spec * garnish); handy for an app-specific flourish the data garnish can't express. * @param contentDescription spoken label for TalkBack / VoiceOver (defaults to [label]). + * @param labelPadding horizontal breathing room reserved around the label. This is what gives a + * width-less (shrink-wrapped) control its natural width, so the sketch edge never clips the first + * or last glyph. Callers that drive the width explicitly (`Modifier.width`/`weight`) still get this + * inset INSIDE that width. Set to `0.dp` for an edge-to-edge label. */ @Composable fun OGControl( @@ -104,30 +113,44 @@ fun OGControl( font: OGTextFont = OGTextFont(), garnish: (@Composable BoxScope.() -> Unit)? = null, contentDescription: String? = label, + labelPadding: Dp = 14.dp, ) { val style = remember(spec.labelStyle, labelStyle) { spec.labelStyle?.toStyle() ?: labelStyle } - val effect = remember(spec.pressEffect) { ogControlPressEffect(spec.pressEffect) } + val effect = remember(spec.pressEffect, spec.depth) { ogControlPressEffect(spec.pressEffect, spec.depth.dp) } val fillColor = if (enabled) backgroundColor else backgroundColor.copy(alpha = spec.disabledAlpha) val textColor = if (enabled) contentColor else contentColor.copy(alpha = 0.5f) val inkColor = spec.outline?.let { contentColor.copy(alpha = if (enabled) it.inkAlpha else it.inkAlpha * 0.44f) } val specFill = spec.fill + val hasDepth = spec.depth > 0f + + val resolvedLipColor = remember(spec.lipColor, fillColor) { + spec.lipColor?.toSpecColor() ?: Color( + red = (fillColor.red * 0.72f).coerceIn(0f, 1f), + green = (fillColor.green * 0.72f).coerceIn(0f, 1f), + blue = (fillColor.blue * 0.72f).coerceIn(0f, 1f), + alpha = fillColor.alpha, + ) + } Box( modifier - .height(spec.height.dp) + .height((spec.height + spec.depth).dp) .clip(RoundedCornerShape(spec.cornerRadius.dp)) .drawWithCache { - val brush: Brush? = fill ?: specFill?.sanitized()?.toBrush(0f, 0f, size.width, size.height) + val depthPx = if (hasDepth) spec.depth.dp.toPx() else 0f + val cornerRadiusPx = spec.cornerRadius.dp.toPx() + val faceH = (size.height - depthPx).coerceAtLeast(0f) + val brush: Brush? = fill ?: specFill?.sanitized()?.toBrush(0f, 0f, size.width, faceH) val o = spec.outline val outlinePath: Path? = o?.let { val pts = ogControlOutline( width = size.width, - height = size.height, - cornerRadius = spec.cornerRadius.dp.toPx(), + height = faceH, + cornerRadius = cornerRadiusPx, inset = it.inset.dp.toPx(), - amplitude = size.height * it.amplitude, + amplitude = faceH * it.amplitude, detail = it.detail, seed = seed, ) @@ -137,8 +160,49 @@ fun OGControl( Stroke(width = it.width.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round) } onDrawBehind { - if (brush != null) drawRect(brush, alpha = if (enabled) 1f else spec.disabledAlpha) - else drawRect(fillColor) + // 1. 3D bottom extrusion lip + if (hasDepth) { + drawRoundRect( + color = resolvedLipColor, + topLeft = Offset(0f, 0f), + size = Size(size.width, size.height), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadiusPx), + ) + } + + // 2. Top face surface + if (brush != null) { + drawRoundRect( + brush, + topLeft = Offset(0f, 0f), + size = Size(size.width, faceH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadiusPx), + alpha = if (enabled) 1f else spec.disabledAlpha, + ) + } else { + drawRoundRect( + fillColor, + topLeft = Offset(0f, 0f), + size = Size(size.width, faceH), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadiusPx), + ) + } + + // 3. Top-edge glossy highlight chamfer + if (spec.highlight && faceH > 0f && enabled) { + drawRoundRect( + brush = Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.28f), Color.White.copy(alpha = 0.02f)), + startY = 0f, + endY = faceH * 0.44f, + ), + topLeft = Offset(0f, 0f), + size = Size(size.width, faceH * 0.44f), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(cornerRadiusPx), + ) + } + + // 4. Sketched ink outline if (outlinePath != null && inkColor != null && stroke != null) { drawPath(outlinePath, inkColor, style = stroke) } @@ -149,7 +213,8 @@ fun OGControl( pressEffect = effect, contentDescription = contentDescription, onClick = onClick, - ), + ) + .padding(bottom = if (hasDepth) spec.depth.dp else 0.dp), contentAlignment = Alignment.Center, ) { if (enabled) { @@ -158,19 +223,22 @@ fun OGControl( spec.garnish != null -> OGControlGarnish(spec.garnish, seed) } } - OGStyledText( - text = label, - style = style, - fontSize = spec.fontSize.sp, - color = textColor, - font = font, - animated = false, - ) + Box(Modifier.padding(horizontal = labelPadding)) { + OGStyledText( + text = label, + style = style, + fontSize = spec.fontSize.sp, + color = textColor, + font = font, + animated = false, + ) + } } } /** The [OGPressEffect] named by an [OGControlSpec.pressEffect] string. */ -fun ogControlPressEffect(name: String): OGPressEffect = when (name.trim().lowercase()) { +fun ogControlPressEffect(name: String, depth: Dp = 4.dp): OGPressEffect = when (name.trim().lowercase()) { + "tactile" -> OGPressEffect.Tactile(depth = depth) "dim" -> OGPressEffect.Dim() "brutalist" -> OGPressEffect.Brutalist() "none" -> OGPressEffect.None diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControlSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControlSpec.kt index 327d99a..e3633bd 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControlSpec.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControlSpec.kt @@ -29,9 +29,15 @@ data class OGControlSpec( val cornerRadius: Float = 11f, /** Control height in `dp` (width comes from the modifier the caller passes). */ val height: Float = 46f, + /** 3D extrusion bottom lip depth in `dp`. `0` = flat button, `3..6` = juicy 3D game button. */ + val depth: Float = 0f, + /** Optional color for the 3D bottom lip (`#RRGGBB`); null = auto-derived darker tone. */ + val lipColor: String? = null, + /** When `true`, renders a glossy top-edge highlight chamfer. */ + val highlight: Boolean = false, /** Label size in `sp`. */ val fontSize: Float = 13f, - /** Pressed-state feedback: `scale` | `dim` | `brutalist` | `none` (case-insensitive). */ + /** Pressed-state feedback: `tactile` | `scale` | `dim` | `brutalist` | `none`. */ val pressEffect: String = "scale", /** Opacity of the whole fill when `enabled == false` (the label dims to half separately). */ val disabledAlpha: Float = 0.4f, diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControls.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControls.kt index 23f876e..0f8e623 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControls.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGControls.kt @@ -57,6 +57,49 @@ object OGControls { "slime" to OGControlSpec(garnish = garnishes.getValue("slime")), // Hand-drawn + winter snow. "snow" to OGControlSpec(garnish = garnishes.getValue("snow")), + // ── 3D Game Presets ────────────────────────────────────────────────── + // Tactile squircle game button: 3D extrusion lip, gloss highlight, press-down action + "game" to OGControlSpec( + cornerRadius = 16f, + height = 46f, + depth = 4.5f, + pressEffect = "tactile", + highlight = true, + outline = null, + labelStyle = OGStyleSpec(), + ), + // Tactile capsule / pill game button (rounded ends) + "game-pill" to OGControlSpec( + cornerRadius = 24f, + height = 46f, + depth = 4.5f, + pressEffect = "tactile", + highlight = true, + outline = null, + labelStyle = OGStyleSpec(), + ), + // Hero / CTA game button (bolder, deeper 3D lip) + "game-hero" to OGControlSpec( + cornerRadius = 18f, + height = 50f, + depth = 5.5f, + pressEffect = "tactile", + highlight = true, + fontSize = 14f, + outline = null, + labelStyle = OGStyleSpec(), + ), + // Compact chip / token button + "game-token" to OGControlSpec( + cornerRadius = 12f, + height = 36f, + depth = 3.5f, + pressEffect = "tactile", + highlight = true, + fontSize = 11f, + outline = null, + labelStyle = OGStyleSpec(), + ), ) /** Look up a [presets] entry by name, or `null` if there is none. */ @@ -77,8 +120,11 @@ object OGControls { { "cornerRadius": , "height": , + "depth": , + "lipColor": <#RRGGBB (null): 3D lip shade>, + "highlight": , "fontSize": , - "pressEffect": "scale" | "dim" | "brutalist" | "none" ("scale"), + "pressEffect": "tactile" | "scale" | "dim" | "brutalist" | "none" ("scale"), "disabledAlpha":<0..1 (0.4): fill opacity when disabled>, "outline": { // the hand-drawn ink edge; null for a clean button "width": , "inset": , diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGGameCard.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGGameCard.kt new file mode 100644 index 0000000..45965db --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/control/OGGameCard.kt @@ -0,0 +1,122 @@ +package com.solidkey.painpoints.control + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A dimensional tactile **Game Card / HUD Placard** primitive. + * Bundles: + * - 3D bottom extrusion bevel lip for physical game depth. + * - Top surface with optional glossy highlight chamfer. + * - Sculpted border rim with optional active glow halo (e.g. active turn indicator). + * + * Used for: + * - Player vs Rival Duel Scoreboard placards + * - Living Board Mat frame + * - Event Broadcast ticker chips + * - Tile Rack trays + */ +@Composable +fun OGGameCard( + modifier: Modifier = Modifier, + backgroundColor: Color = Color.White, + lipColor: Color? = null, + borderColor: Color? = null, + glowColor: Color? = null, + cornerRadius: Dp = 14.dp, + depth: Dp = 4.dp, + borderWidth: Dp = 1.5.dp, + highlight: Boolean = true, + content: @Composable BoxScope.() -> Unit, +) { + val cornerRadiusPx = cornerRadius + val depthPx = depth + + Box( + modifier = modifier + .drawBehind { + val cr = cornerRadiusPx.toPx() + val d = depthPx.toPx() + val w = size.width + val h = size.height + + // 1. Optional active glow halo behind the card + if (glowColor != null && glowColor != Color.Transparent) { + val haloR = 6.dp.toPx() + drawRoundRect( + color = glowColor.copy(alpha = 0.35f), + topLeft = Offset(-haloR * 0.5f, -haloR * 0.5f), + size = Size(w + haloR, h + haloR), + cornerRadius = CornerRadius(cr + haloR * 0.5f), + ) + } + + // 2. Bottom 3D extrusion lip + if (d > 0f) { + val actualLip = lipColor ?: Color( + red = (backgroundColor.red * 0.75f).coerceIn(0f, 1f), + green = (backgroundColor.green * 0.75f).coerceIn(0f, 1f), + blue = (backgroundColor.blue * 0.75f).coerceIn(0f, 1f), + alpha = backgroundColor.alpha, + ) + drawRoundRect( + color = actualLip, + topLeft = Offset(0f, 0f), + size = Size(w, h), + cornerRadius = CornerRadius(cr), + ) + } + + // 3. Top surface face + val faceH = (h - d).coerceAtLeast(0f) + drawRoundRect( + color = backgroundColor, + topLeft = Offset(0f, 0f), + size = Size(w, faceH), + cornerRadius = CornerRadius(cr), + ) + + // 4. Subtle glossy highlight chamfer along the top edge + if (highlight && faceH > 0f) { + drawRoundRect( + brush = Brush.verticalGradient( + listOf(Color.White.copy(alpha = 0.30f), Color.White.copy(alpha = 0.02f)), + startY = 0f, + endY = faceH * 0.45f, + ), + topLeft = Offset(0f, 0f), + size = Size(w, faceH * 0.45f), + cornerRadius = CornerRadius(cr), + ) + } + + // 5. Sculpted border outline + if (borderColor != null && borderWidth > 0.dp) { + drawRoundRect( + color = borderColor, + topLeft = Offset(0f, 0f), + size = Size(w, faceH), + cornerRadius = CornerRadius(cr), + style = Stroke(width = borderWidth.toPx()), + ) + } + } + .clip(RoundedCornerShape(cornerRadius)) + .padding(bottom = depth), + content = content, + ) +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGAurora.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGAurora.kt new file mode 100644 index 0000000..983b94d --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGAurora.kt @@ -0,0 +1,75 @@ +package com.solidkey.painpoints.fx + +import com.solidkey.painpoints.ai.OGAiVector + +/** + * Codec + presets for [OGAuroraSpec]. + * + * Presets: + * - [NORTHERN] — classic Arctic green/cyan/violet, slow drift + * - [POLAR] — cold blue-white polar glow, minimal shear + * - [DREAMY] — warm pink/gold/purple Shinkai-style, fast and wide + */ +object OGAurora { + val json get() = OGAiVector.json + + fun decodeSpec(text: String): OGAuroraSpec = + json.decodeFromString(OGAuroraSpec.serializer(), OGAiVector.extractJson(text)) + + fun decodeSpecOrNull(text: String): OGAuroraSpec? = runCatching { decodeSpec(text) }.getOrNull() + + fun encode(spec: OGAuroraSpec): String = json.encodeToString(OGAuroraSpec.serializer(), spec) + + fun preset(name: String): OGAuroraSpec? = presets[name.lowercase()] + + fun auroraPrompt(instruction: String): String = """ + You output ONLY a JSON object describing an aurora borealis / australis curtain for the KMPMedia library. + + It renders as luminous multi-harmonic vertical curtain rays with altitude-stratified colour + (bottomColor = low-altitude, typically green; topColor = high-altitude, typically violet/pink). + Coordinates: curtainY and curtainHeight are fractions of view height; amplitude and shear are + fractions of view width. Speed and shearSpeed are in scene units per second. + + Fields (all optional): name, columns, bottomColor, midColor, topColor, harmonics, frequency, + amplitude, shear, shearSpeed, columnWidthFactor, curtainY, curtainHeight, speed, opacity, seed. + + Examples: + {"name":"northern","bottomColor":"#00E676","midColor":"#00BCD4","topColor":"#CE93D8","columns":7,"speed":0.22} + {"name":"polar","bottomColor":"#80D8FF","midColor":"#FFFFFF","topColor":"#8C9EFF","columns":5,"opacity":0.55} + {"name":"dreamy","bottomColor":"#FFD54F","midColor":"#F48FB1","topColor":"#CE93D8","columns":9,"speed":0.45,"shear":0.28} + + $instruction + Output ONLY the JSON object. + """.trimIndent() + + // ── Presets ────────────────────────────────────────────────────────────────────────────────── + + /** Classic Arctic green/cyan/violet — *Your Name*, *Frieren*. */ + val NORTHERN = OGAuroraSpec( + name = "northern", + columns = 7, harmonics = 3, frequency = 2.6f, amplitude = 0.06f, + bottomColor = "#00E676", midColor = "#00BCD4", topColor = "#CE93D8", + shear = 0.16f, shearSpeed = 0.18f, columnWidthFactor = 1.55f, + curtainY = 0.06f, curtainHeight = 0.74f, speed = 0.22f, opacity = 0.72f, + ) + + /** Cold blue-white polar — *Mushishi*, minimalist glow. */ + val POLAR = OGAuroraSpec( + name = "polar", + columns = 5, harmonics = 2, frequency = 1.8f, amplitude = 0.05f, + bottomColor = "#80D8FF", midColor = "#E1F5FE", topColor = "#8C9EFF", + shear = 0.08f, shearSpeed = 0.10f, columnWidthFactor = 2.0f, + curtainY = 0.10f, curtainHeight = 0.68f, speed = 0.12f, opacity = 0.55f, + ) + + /** Warm pink/gold/purple — fast, wide, Shinkai-style fantasy aurora. */ + val DREAMY = OGAuroraSpec( + name = "dreamy", + columns = 9, harmonics = 4, frequency = 3.2f, amplitude = 0.09f, + bottomColor = "#FFD54F", midColor = "#F48FB1", topColor = "#CE93D8", + shear = 0.28f, shearSpeed = 0.32f, columnWidthFactor = 1.40f, + curtainY = 0.04f, curtainHeight = 0.78f, speed = 0.45f, opacity = 0.68f, + ) + + val presets: Map = listOf(NORTHERN, POLAR, DREAMY).associateBy { it.name!! } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGAuroraSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGAuroraSpec.kt new file mode 100644 index 0000000..eb82411 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGAuroraSpec.kt @@ -0,0 +1,65 @@ +package com.solidkey.painpoints.fx + +import kotlinx.serialization.Serializable + +/** + * Describes an aurora borealis / australis curtain effect — multi-harmonic vertical rays with + * altitude-stratified colour (emerald 557.7nm at ~100km → cyan → violet/magenta 630nm at ~200km+), + * anisotropic simplex-noise shear, and additive-screen blending. + * + * Algorithmically: N curtain columns, each a subdivided vertical strip whose horizontal position + * follows several harmonics of a sine wave (curvature increases toward the bottom — like a hanging + * curtain). Simplex fBm shear adds organic life along the length. Drawn with [BlendMode.Screen] + * for luminous glow without over-saturation. + * + * Pure `commonMain` math — zero shaders, zero per-pixel work, 60fps. + * + * Presets and codec on [OGAurora]. Rendered by [OGAuroraView] / [drawOGAuroraFrame]. + */ +@Serializable +data class OGAuroraSpec( + val name: String? = null, + /** Number of parallel curtain columns (more = denser, richer curtain). */ + val columns: Int = 7, + /** Bottom-of-curtain colour — oxygen 557.7nm at ~100km altitude, typically emerald green. */ + val bottomColor: String = "#00E676", + /** Mid-curtain colour — the transition zone. */ + val midColor: String = "#00BCD4", + /** Top-of-curtain colour — oxygen 630nm at ~200km+, typically violet/magenta. */ + val topColor: String = "#CE93D8", + /** Number of harmonic layers added to each column's curvature. More = more complex waving. */ + val harmonics: Int = 3, + /** Spatial frequency of the primary sway (higher = more oscillations along the curtain). */ + val frequency: Float = 2.6f, + /** Maximum horizontal sway amplitude as a fraction of view width. */ + val amplitude: Float = 0.06f, + /** Simplex fBm shear strength (random local bending) as fraction of view width. */ + val shear: Float = 0.16f, + /** Horizontal scroll speed of the shear noise (frac/sec). */ + val shearSpeed: Float = 0.18f, + /** Curtain column width as a fraction of inter-column spacing (>1 = overlapping columns). */ + val columnWidthFactor: Float = 1.55f, + /** Top of the curtain as a fraction of view height (0 = top edge, 0.3 = 30% down). */ + val curtainY: Float = 0.06f, + /** Curtain height as a fraction of view height. */ + val curtainHeight: Float = 0.74f, + /** Animation speed — multiplier on all time-driven motion. */ + val speed: Float = 0.22f, + /** Overall opacity (0..1). */ + val opacity: Float = 0.72f, + val seed: Int = 0, +) { + fun sanitized() = copy( + columns = columns.coerceIn(2, 40), + harmonics = harmonics.coerceIn(1, 8), + frequency = frequency.coerceIn(0.5f, 12f), + amplitude = amplitude.coerceIn(0f, 0.5f), + shear = shear.coerceIn(0f, 0.6f), + shearSpeed = shearSpeed.coerceIn(0f, 4f), + columnWidthFactor = columnWidthFactor.coerceIn(0.5f, 4f), + curtainY = curtainY.coerceIn(0f, 0.9f), + curtainHeight = curtainHeight.coerceIn(0.05f, 1f), + speed = speed.coerceIn(0f, 5f), + opacity = opacity.coerceIn(0f, 1f), + ) +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGAuroraView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGAuroraView.kt new file mode 100644 index 0000000..8e8d66d --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGAuroraView.kt @@ -0,0 +1,148 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import com.solidkey.painpoints.ai.toSpecColor +import kotlin.math.PI +import kotlin.math.floor +import kotlin.math.pow +import kotlin.math.sin + +/** + * Renders an aurora borealis curtain that fills this composable's bounds. Set [playing] to + * `false` to freeze. Overlay it over a dark scene with a `Box`; the screen-blend columns are + * transparent where there is no curtain. + * + * Algorithm: N curtain columns, each a subdivided vertical strip whose horizontal displacement + * follows H harmonics of a sinusoid (curvature maximal at the bottom) plus simplex fBm shear. + * Fill = altitude-stratified vertical gradient (bottom=557nm green → mid=cyan → top=630nm violet). + * Drawn with [BlendMode.Screen] so overlapping columns build luminous brightness without clipping. + * + * Presets and codec on [OGAurora]. + */ +@Composable +fun OGAuroraView( + spec: OGAuroraSpec, + modifier: Modifier = Modifier, + playing: Boolean = true, +) { + val s = remember(spec) { spec.sanitized() } + val bottomC = remember(s) { s.bottomColor.toSpecColor() ?: Color(0xFF00E676) } + val midC = remember(s) { s.midColor.toSpecColor() ?: Color(0xFF00BCD4) } + val topC = remember(s) { s.topColor.toSpecColor() ?: Color(0xFFCE93D8) } + + var elapsed by remember(s) { mutableStateOf(0f) } + LaunchedEffect(s, playing) { + if (!playing) return@LaunchedEffect + val start = withFrameMillis { it } + while (true) withFrameMillis { elapsed = (it - start).toFloat() } + } + + Canvas(modifier.clipToBounds()) { + drawOGAuroraFrame(s, elapsed / 1000f, bottomC, midC, topC) + } +} + +/** + * Draw one frame of an aurora curtain at [timeSec] with resolved colours. + * The per-frame body shared by [OGAuroraView] and any off-screen baker. + */ +fun DrawScope.drawOGAuroraFrame( + spec: OGAuroraSpec, + timeSec: Float, + bottomColor: Color, + midColor: Color, + topColor: Color, +) { + val w = size.width; val h = size.height + if (w <= 0f || h <= 0f) return + val s = spec.sanitized() + + val curtainTop = s.curtainY * h + val curtainH = s.curtainHeight * h + val N = s.columns + val step = if (N > 1) w / (N - 1).toFloat() else w + val halfW = step * s.columnWidthFactor * 0.5f + val subdivs = 28 + + val twoPI = 2f * PI.toFloat() + + for (col in 0 until N) { + val baseX = col * step + val phase0 = col * twoPI / N + OGFxMath.hash01(col, s.seed) * twoPI + val alphaJitter = 0.55f + 0.45f * OGFxMath.hash01(col * 17 + 3, s.seed + 5) + val alpha = s.opacity * alphaJitter + + // Build left and right edges of this column strip (subdivided for smooth curvature) + val leftX = FloatArray(subdivs + 1) + val leftY = FloatArray(subdivs + 1) + val rightX = FloatArray(subdivs + 1) + val rightY = FloatArray(subdivs + 1) + + for (j in 0..subdivs) { + val hFrac = j.toFloat() / subdivs // 0 = top, 1 = bottom of curtain + val y = curtainTop + hFrac * curtainH + + // Curvature grows quadratically toward the bottom (hanging curtain physics) + val curvature = hFrac * hFrac + + // Multi-harmonic sway + var sway = 0f + for (harm in 1..s.harmonics) { + val harmAmp = s.amplitude * w / harm.toFloat().pow(1.4f) + val harmFreq = s.frequency * harm + val harmPhase = phase0 + harm * 0.73f + sway += curvature * harmAmp * sin(harmFreq * hFrac * twoPI + harmPhase + s.speed * timeSec) + } + + // Simplex fBm shear: non-uniform organic bending along the column length + val shearX = s.shear * w * OGFxNoise.fbm( + x = hFrac * 2.8f + col * 0.52f, + y = timeSec * s.shearSpeed + col * 0.31f, + octaves = 2, + seed = s.seed + col, + ) + + val cx = baseX + sway + shearX + leftX[j] = cx - halfW; leftY[j] = y + rightX[j] = cx + halfW; rightY[j] = y + } + + // Build closed path: left edge top→bottom, right edge bottom→top + val path = Path() + path.moveTo(leftX[0], leftY[0]) + for (j in 1..subdivs) path.lineTo(leftX[j], leftY[j]) + for (j in subdivs downTo 0) path.lineTo(rightX[j], rightY[j]) + path.close() + + // Vertical gradient: top=topColor (dim) → mid=midColor → bottom=bottomColor (bright) + val gradStart = Offset(w * 0.5f, curtainTop) + val gradEnd = Offset(w * 0.5f, curtainTop + curtainH) + val fill = Brush.linearGradient( + colorStops = arrayOf( + 0f to topColor.copy(alpha = alpha * 0.38f), + 0.30f to midColor.copy(alpha = alpha * 0.68f), + 1f to bottomColor.copy(alpha = alpha), + ), + start = gradStart, + end = gradEnd, + ) + drawPath(path, fill, blendMode = BlendMode.Screen) + } +} + +private fun frac(x: Float): Float = x - floor(x) diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGBubble.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGBubble.kt new file mode 100644 index 0000000..0db9907 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGBubble.kt @@ -0,0 +1,41 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import kotlin.math.max + +/** + * Draw one glossy soap/water bubble: a translucent radial-gradient body (lit from the top-left), + * a bright rim, and two specular sparkles. No shader, no per-pixel work — pure [DrawScope] circles. + * + * [cx]/[cy] are the centre in pixels (not normalised). [r] is the radius in pixels. [tint] sets + * the bubble colour; [alpha] fades everything (use 0..1 for pop and fade animations). + */ +fun DrawScope.drawOGBubbleFrame(cx: Float, cy: Float, r: Float, tint: Color, alpha: Float) { + val c = Offset(cx, cy) + // Body: mostly see-through, brighter where the light hits top-left, deepening to tint at the rim. + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.45f * alpha), + tint.copy(alpha = 0.30f * alpha), + tint.copy(alpha = 0.12f * alpha), + ), + center = Offset(cx - r * 0.30f, cy - r * 0.34f), + radius = r * 1.45f, + ), + radius = r, + center = c, + ) + // Rim light — the thin bright edge that reads as "bubble". + drawCircle(color = Color.White.copy(alpha = 0.42f * alpha), radius = r, center = c, + style = Stroke(width = max(1f, r * 0.05f))) + // Specular highlights near the top-left. + drawCircle(color = Color.White.copy(alpha = 0.78f * alpha), radius = r * 0.17f, + center = Offset(cx - r * 0.33f, cy - r * 0.37f)) + drawCircle(color = Color.White.copy(alpha = 0.40f * alpha), radius = r * 0.08f, + center = Offset(cx + r * 0.12f, cy - r * 0.44f)) +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGCrack.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGCrack.kt new file mode 100644 index 0000000..ea4a414 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGCrack.kt @@ -0,0 +1,86 @@ +package com.solidkey.painpoints.fx + +import com.solidkey.painpoints.ai.OGAiVector + +/** + * The **codec + presets** for [OGCrackSpec] — decode a JSON pack, encode one back, build a model prompt, + * and reach for a ready-made surface ([GROUND], [ICE], [TILE], [GLASS]). Mirrors the rest of the FX / + * data layers: lenient JSON, tolerant extraction, no network, no AI SDK. + */ +object OGCrack { + val json get() = OGAiVector.json + + fun decodeSpec(text: String): OGCrackSpec = + json.decodeFromString(OGCrackSpec.serializer(), OGAiVector.extractJson(text)) + + fun decodeSpecOrNull(text: String): OGCrackSpec? = runCatching { decodeSpec(text) }.getOrNull() + + fun encode(spec: OGCrackSpec): String = json.encodeToString(OGCrackSpec.serializer(), spec) + + /** Look up a built-in preset by name (case-insensitive), or `null`. */ + fun preset(name: String): OGCrackSpec? = presets[name.lowercase()] + + fun crackPrompt(instruction: String): String = """ + You output ONLY a JSON object describing a fracture / crack burst for the KMPMedia library. + + It renders as jagged crack arms growing outward from an impact point as an external progress goes + 0..1: tapered strokes (root=full width, tip=12%), directional cast shadow for 3D relief, an + epicenter void crater, and leading tip sparks. length, crackWidth and shadowWidth are fractions + of the view's shorter side; irregularity 0..1 is how jagged the arms bend; taper 0..0.95 is + root-to-tip width falloff; voidRadius is the impact crater fraction; tipGlowRadius is the leading + spark size. + + Fields (all optional): name, crackColor, shadowColor, arms, depth, length, crackWidth, + shadowWidth, irregularity, seed, taper, voidColor, voidRadius, tipGlowColor, tipGlowRadius, + lightAngleDeg. + + Schema: {"name":"", ...fields...} + Example: {"name":"ice","crackColor":"#E3F2FD","shadowColor":"#1565C0","arms":12,"depth":2,"length":0.35,"irregularity":0.7,"taper":0.85,"voidRadius":0.02,"tipGlowColor":"#FFFFFF"} + + Output the JSON only, with no prose and no markdown fences. + + Task: $instruction + """.trimIndent() + + /** Wide rough earthquake cracks in the ground — the classic impact fracture. */ + val GROUND = OGCrackSpec( + name = "ground", crackColor = "#D7CCC8", shadowColor = "#1C110A", voidColor = "#0A0604", + arms = 7, depth = 1, length = 0.42f, crackWidth = 0.014f, shadowWidth = 0.034f, + irregularity = 0.55f, taper = 0.82f, voidRadius = 0.045f, + tipGlowColor = "#FFCC80", tipGlowRadius = 0.014f, + ) + + /** Thin blue-white spider-web — many fine branching arms, like cracking lake ice. */ + val ICE = OGCrackSpec( + name = "ice", crackColor = "#E1F5FE", shadowColor = "#0D47A1", voidColor = "#011A38", + arms = 13, depth = 2, length = 0.38f, crackWidth = 0.007f, shadowWidth = 0.018f, + irregularity = 0.72f, taper = 0.85f, voidRadius = 0.020f, + tipGlowColor = "#FFFFFF", tipGlowRadius = 0.012f, + ) + + /** Hairline cracks on a rigid tile — few arms, short, barely bending. */ + val TILE = OGCrackSpec( + name = "tile", crackColor = "#757575", shadowColor = "#1A1A1A", voidColor = "#000000", + arms = 5, depth = 1, length = 0.30f, crackWidth = 0.008f, shadowWidth = 0.020f, + irregularity = 0.30f, taper = 0.65f, voidRadius = 0.025f, tipGlowRadius = 0.008f, + ) + + /** Fine radial glass fracture — very thin, very angular, densely webbed. */ + val GLASS = OGCrackSpec( + name = "glass", crackColor = "#FFFFFF", shadowColor = "#37474F", voidColor = "#102027", + arms = 10, depth = 2, length = 0.35f, crackWidth = 0.005f, shadowWidth = 0.012f, + irregularity = 0.85f, taper = 0.90f, voidRadius = 0.010f, voidDepth = 0.5f, + tipGlowColor = "#FFFFFF", tipGlowRadius = 0.015f, + ) + + /** A devastating, deeply cinematic tectonic quake hole with massive chasm depth and wide arms. */ + val QUAKE = OGCrackSpec( + name = "quake", crackColor = "#FFE0B2", shadowColor = "#3E2723", voidColor = "#000000", + arms = 12, depth = 3, length = 0.60f, crackWidth = 0.024f, shadowWidth = 0.050f, + irregularity = 0.80f, taper = 0.70f, voidRadius = 0.080f, voidDepth = 2.8f, + tipGlowColor = "#FF9800", tipGlowRadius = 0.025f, lightAngleDeg = 280f, + ) + + /** All built-in presets, keyed by lowercase name. */ + val presets: Map = listOf(GROUND, ICE, TILE, GLASS, QUAKE).associateBy { it.name!! } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGCrackSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGCrackSpec.kt new file mode 100644 index 0000000..e427ef8 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGCrackSpec.kt @@ -0,0 +1,292 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import com.solidkey.painpoints.ai.toSpecColor +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.sin +import kotlin.random.Random + +/** + * **Crack / fracture** — jagged crack lines radiating from an impact point, described as plain data. + * Part of the `com.solidkey.painpoints.fx` family. Decode + presets on [OGCrack] ([OGCrack.GROUND] wide + * rough earth cracks, [OGCrack.ICE] thin spider-web, [OGCrack.TILE] hairline board cracks, + * [OGCrack.GLASS] fine radial web); resolve the geometry once with [crackArms], then draw each frame + * with [drawOGCrackFrame]. + * + * A caller-driven `progress 0..1` grows the cracks outward from the origin — each arm's polyline is + * revealed segment by segment, with the leading segment drawn partially, so the fracture visibly + * *spreads*. Rendering uses: a directional cast-shadow (depth relief), a tapered bright crack line, + * an epicenter impact void, and leading tip sparks — plain `drawLine` / `drawPath` calls, no shader. + * Geometry is pure + deterministic from [seed], so Android & iOS are frame-identical. + */ +@kotlinx.serialization.Serializable +data class OGCrackSpec( + val name: String? = null, + /** Main visible crack line colour — any SVG colour string. */ + val crackColor: String = "#CCCCCC", + /** Dark shadow drawn under the crack (reads as depth). */ + val shadowColor: String = "#050302", + /** Number of main crack arms radiating from the origin. Clamped `2..20`. */ + val arms: Int = 7, + /** Branching sub-crack generations per arm (`0` = no branches). Clamped `0..3`. */ + val depth: Int = 1, + /** Max reach of each arm, as a fraction of the view's shorter side. */ + val length: Float = 0.40f, + /** Main crack stroke width, as a fraction of the view's shorter side. */ + val crackWidth: Float = 0.012f, + /** Shadow stroke width (wider than the crack), as a fraction of the view's shorter side. */ + val shadowWidth: Float = 0.028f, + /** How jagged the arms are: `0` = straight rays, `1` = very angular. */ + val irregularity: Float = 0.5f, + /** Seed for the deterministic fracture layout. */ + val seed: Int = 0, + /** Fraction to taper arm stroke root-to-tip (`0` = uniform, `0.85` tapers to 15% at tip). */ + val taper: Float = 0.75f, + /** Dark chasm / void fill colour at the central impact crater. */ + val voidColor: String = "#000000", + /** Radius of central impact void as fraction of minSide (`0` = disabled). */ + val voidRadius: Float = 0.035f, + /** Deepness multiplier for the impact crater chasm. */ + val voidDepth: Float = 1.2f, + /** Bright glow colour at the advancing crack tip. */ + val tipGlowColor: String = "#FFFFFF", + /** Size of leading tip spark as fraction of minSide (`0` = disabled). */ + val tipGlowRadius: Float = 0.012f, + /** Ambient light angle in degrees — shifts shadow pass for 3D crevice relief. */ + val lightAngleDeg: Float = 315f, +) { + /** Clamp every field into its sane range (lenient model/designer input never breaks a frame). */ + fun sanitized(): OGCrackSpec = copy( + arms = arms.coerceIn(2, 20), + depth = depth.coerceIn(0, 3), + length = length.coerceIn(0.05f, 1f), + crackWidth = crackWidth.coerceIn(0.001f, 0.1f), + shadowWidth = shadowWidth.coerceIn(0.001f, 0.2f), + irregularity = irregularity.coerceIn(0f, 1f), + taper = taper.coerceIn(0f, 0.95f), + voidRadius = voidRadius.coerceIn(0f, 0.2f), + voidDepth = voidDepth.coerceIn(0f, 5f), + tipGlowRadius = tipGlowRadius.coerceIn(0f, 0.05f), + ) +} + +/** One point on a crack arm, normalized `0..1` of the board/view. */ +data class OGCrackPoint(val x: Float, val y: Float) + +/** + * One resolved crack arm: a list of [OGCrackPoint] from its start outward. + * [startFraction] is the global `progress` value at which this arm becomes visible — 0f for main + * arms (they start immediately), and `> 0f` for sub-branches (they only appear once the parent + * trunk has grown past the branch spawn point). + */ +data class OGCrackArm(val points: List, val startFraction: Float = 0f) + +/** + * Resolve a [spec]'s fracture geometry around the normalized origin `(originX, originY)` — pure + + * deterministic from [OGCrackSpec.seed], so the same `(spec, origin)` always yields the same arms (and + * both platforms match). Main arms spread evenly around 360° with a small per-arm jitter, step outward + * in 6–8 bending segments, and (when [OGCrackSpec.depth] `>= 1`) spawn shorter sub-branches partway + * along. Build once per impact, then feed the list to [drawOGCrackFrame] every frame. + */ +fun crackArms(spec: OGCrackSpec, originX: Float, originY: Float): List { + val s = spec.sanitized() + val r = Random(s.seed) + val out = mutableListOf() + for (i in 0 until s.arms) { + val baseAngle = i * (360f / s.arms) + OGFxMath.hashSigned(i, s.seed) * 15f + val segCount = 6 + (i % 3) + growArm(out, s, r, originX, originY, baseAngle, s.length, segCount, s.depth) + } + return out +} + +/** + * Grow one arm as a bending polyline, recursively spawning sub-branches while [depthLeft] allows. + * [startFraction] is the global progress value at which this arm first becomes visible. + */ +private fun growArm( + out: MutableList, + spec: OGCrackSpec, + r: Random, + startX: Float, + startY: Float, + startAngle: Float, + length: Float, + segCount: Int, + depthLeft: Int, + startFraction: Float = 0f, +) { + var angle = startAngle + var x = startX + var y = startY + val segLen = length / segCount + val points = mutableListOf(OGCrackPoint(x, y)) + val branchAt = (segCount * 2) / 3 + for (seg in 0 until segCount) { + val step = segLen * (0.7f + r.nextFloat() * 0.6f) + val rad = angle * (PI.toFloat() / 180f) + x += cos(rad) * step + y += sin(rad) * step + points += OGCrackPoint(x, y) + if (depthLeft >= 1 && seg == branchAt) { + val side = if (r.nextFloat() < 0.5f) -1f else 1f + val deviation = side * (25f + 15f * spec.irregularity) + val remaining = segLen * (segCount - seg - 1) + val subSegs = (segCount / 2).coerceAtLeast(2) + val branchStart = startFraction + (branchAt.toFloat() / segCount) * (1f - startFraction) + growArm(out, spec, r, x, y, angle + deviation, remaining * 0.5f, subSegs, depthLeft - 1, branchStart) + } + angle += r.nextFloat() * 44f * spec.irregularity - 22f * spec.irregularity + } + out += OGCrackArm(points, startFraction) +} + +/** + * Draw one frame of a growing fracture into the current [DrawScope]: the cached [arms] (from + * [crackArms]), re-anchored at the normalized `(originX, originY)`, revealed up to [progress] `0..1`. + * + * Rendering layers (back to front): + * 1. **Impact void** — an irregular filled polygon at the epicenter. + * 2. **Cast-shadow pass** — shadow stroke offset along the ambient light angle for 3D relief. + * 3. **Crack pass** — tapered bright crack line (root = full width, tip = 12% of root). + * 4. **Tip sparks** — dual-circle glow at each advancing fracture tip while spreading. + */ +fun DrawScope.drawOGCrackFrame( + spec: OGCrackSpec, + arms: List, + originX: Float, + originY: Float, + progress: Float, + boardW: Float, + boardH: Float, +) { + if (progress <= 0f || arms.isEmpty() || boardW <= 0f || boardH <= 0f) return + val s = spec.sanitized() + val p = progress.coerceAtMost(1f) + val minSide = min(boardW, boardH) + val shadow = s.shadowColor.toSpecColor() ?: Color.Black + val crack = s.crackColor.toSpecColor() ?: Color.White + val voidCol = s.voidColor.toSpecColor() ?: Color.Black + val tipCol = s.tipGlowColor.toSpecColor() ?: Color.White + + val base = arms.first().points.first() + val dx = originX - base.x + val dy = originY - base.y + + // Directional light offset for 3D cast-shadow relief + val lightRad = s.lightAngleDeg * (PI.toFloat() / 180f) + val shadowOffX = cos(lightRad) * (s.shadowWidth * minSide * 0.35f) + val shadowOffY = sin(lightRad) * (s.shadowWidth * minSide * 0.35f) + + // 1. Central Impact Void (irregular polygon crater) + if (s.voidRadius > 0f) { + val voidGrow = (p * 2.5f).coerceAtMost(1f) + val vr = s.voidRadius * minSide * voidGrow + if (vr > 0.5f) { + val voidPath = Path() + val steps = 14 + for (i in 0 until steps) { + val ang = i * (2f * PI.toFloat() / steps) + val jitter = 0.8f + 0.4f * OGFxMath.hash01(i, s.seed) + val vx = (originX * boardW) + cos(ang) * vr * jitter + val vy = (originY * boardH) + sin(ang) * vr * jitter + if (i == 0) voidPath.moveTo(vx, vy) else voidPath.lineTo(vx, vy) + } + voidPath.close() + + // Draw deep 3D chasm with multiple concentric layers falling into pure darkness + drawPath(voidPath, color = shadow.copy(alpha = 0.8f)) + val layerCount = (4 * s.voidDepth).toInt().coerceIn(4, 12) + for (layer in 1..layerCount) { + val depthR = vr * (1f - layer * (0.22f / s.voidDepth)) + if (depthR > 0f) { + val dPath = Path() + val pX = shadowOffX * layer * 0.15f * s.voidDepth + val pY = shadowOffY * layer * 0.15f * s.voidDepth + for (i in 0 until steps) { + val ang = i * (2f * PI.toFloat() / steps) + val jitter = 0.8f + 0.4f * OGFxMath.hash01(i, s.seed + layer) // distinct jagged inner edge + val vx = (originX * boardW) + pX + cos(ang) * depthR * jitter + val vy = (originY * boardH) + pY + sin(ang) * depthR * jitter + if (i == 0) dPath.moveTo(vx, vy) else dPath.lineTo(vx, vy) + } + dPath.close() + val layerAlpha = (0.5f + (layer.toFloat() / layerCount) * 0.5f).coerceIn(0f, 1f) + drawPath(dPath, color = voidCol.copy(alpha = layerAlpha)) + } + } + drawPath(voidPath, color = shadow.copy(alpha = 0.8f), style = Stroke(width = s.crackWidth * minSide)) + } + } + + // Collect tip positions for the spark pass + val tipPositions = mutableListOf() + + fun pass(color: Color, widthFrac: Float, offsetX: Float = 0f, offsetY: Float = 0f, recordTips: Boolean = false) { + val baseStroke = widthFrac * minSide + for (arm in arms) { + if (p <= arm.startFraction) continue + val armP = if (arm.startFraction >= 1f) 0f + else ((p - arm.startFraction) / (1f - arm.startFraction)).coerceIn(0f, 1f) + val pts = arm.points + val totalSegs = pts.size - 1 + if (totalSegs < 1) continue + val drawn = armP * totalSegs + val full = floor(drawn).toInt() + + fun at(idx: Int) = Offset( + (pts[idx].x + dx) * boardW + offsetX, + (pts[idx].y + dy) * boardH + offsetY, + ) + + // Sub-branches are 30% thinner than main trunks + val branchScale = if (arm.startFraction > 0f) 0.70f else 1.0f + + for (seg in 0 until min(full, totalSegs)) { + val segT = seg.toFloat() / totalSegs + val taperMult = (1f - s.taper * segT).coerceAtLeast(0.12f) * branchScale + val stroke = (baseStroke * taperMult).coerceAtLeast(1f) + drawLine(color, at(seg), at(seg + 1), strokeWidth = stroke, cap = StrokeCap.Round) + } + + val frac = drawn - full + if (full < totalSegs && frac > 0f) { + val a = at(full) + val b = at(full + 1) + val tip = Offset(OGFxMath.lerp(a.x, b.x, frac), OGFxMath.lerp(a.y, b.y, frac)) + val tipT = drawn / totalSegs + val taperMult = (1f - s.taper * tipT).coerceAtLeast(0.12f) * branchScale + val stroke = (baseStroke * taperMult).coerceAtLeast(1f) + drawLine(color, a, tip, strokeWidth = stroke, cap = StrokeCap.Round) + if (recordTips && p < 0.98f) { + tipPositions += Offset( + OGFxMath.lerp(a.x - offsetX, b.x - offsetX, frac), + OGFxMath.lerp(a.y - offsetY, b.y - offsetY, frac), + ) + } + } + } + } + + // 2. Cast-shadow pass (offset for 3D depth) + pass(shadow, s.shadowWidth, offsetX = shadowOffX, offsetY = shadowOffY) + // 3. Main crack pass (tapered) + pass(crack, s.crackWidth, recordTips = true) + + // 4. Leading Tip Sparks (fracture stress concentration) + if (s.tipGlowRadius > 0f) { + val sparkR = s.tipGlowRadius * minSide * (1f - p * 0.4f) + for (tip in tipPositions) { + drawCircle(tipCol.copy(alpha = 0.45f), radius = sparkR * 1.8f, center = tip) + drawCircle(Color.White.copy(alpha = 0.90f), radius = sparkR * 0.7f, center = tip) + } + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGCrackView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGCrackView.kt new file mode 100644 index 0000000..1146336 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGCrackView.kt @@ -0,0 +1,26 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds + +/** + * Renders a fracture / crack network ([spec]) growing from ([originX], [originY]) as [progress] + * runs `0..1`. The view is transparent everywhere it isn't drawn. + */ +@Composable +fun OGCrackView( + spec: OGCrackSpec, + modifier: Modifier = Modifier, + originX: Float = 0.5f, + originY: Float = 0.5f, + progress: Float = 1f, +) { + val s = remember(spec) { spec.sanitized() } + val arms = remember(s, originX, originY) { crackArms(s, originX, originY) } + Canvas(modifier.clipToBounds()) { + drawOGCrackFrame(s, arms, originX, originY, progress, size.width, size.height) + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGEvents.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGEvents.kt new file mode 100644 index 0000000..3f8643c --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGEvents.kt @@ -0,0 +1,1937 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.dp + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.sp +import com.solidkey.painpoints.ai.OGFillSpec +import com.solidkey.painpoints.ai.OGPlacedShapeSpec +import com.solidkey.painpoints.ai.OGPointSpec +import com.solidkey.painpoints.ai.OGSceneSpec +import com.solidkey.painpoints.ai.OGStopSpec +import com.solidkey.painpoints.compositor.OGEasing +import com.solidkey.painpoints.look.OGBlendMode +import com.solidkey.painpoints.motion.OGSpring +import com.solidkey.painpoints.motion.OGSpringSpec +import com.solidkey.painpoints.particle.OGParticleSpec +import com.solidkey.painpoints.particle.OGParticleView +import com.solidkey.painpoints.particle.OGParticles +import com.solidkey.painpoints.stage.OGStage +import com.solidkey.painpoints.stage.OGStageLayer +import com.solidkey.painpoints.stage.OGStages +import com.solidkey.painpoints.style.OGStyleOp +import com.solidkey.painpoints.style.OGStyleSpec +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.random.Random +import kotlinx.coroutines.delay + +/** + * **Cinematic Natural Disaster & Action Event Suite** + * + * Provides ready-to-run, anime-grade composable views and multi-layered [OGStage] movies for the + * three quintessential dramatic natural events: + * 1. **Thunder (落雷 / 雷雨)** — Screen flash strobe, forked fractal lightning bolt, airburst shockwave, + * ground sparks, driving rain, and camera jolt. + * 2. **Earthquake (地震 / 地割れ)** — Dual-phase seismic tremor (P-wave rumble + S-wave surface jolt), + * propagating tectonic ground chasm, 2:1 ground-roll shockwave, and rising rock dust motes. + * 3. **Flood (激流 / 氾濫)** — Surging multi-tiered Gerstner waves with rising inundation waterline, + * wind-whipped foam spray droplets, and surface impact ripples. + */ +object OGEvents { + + /** + * Map a grid tile coordinate ([col], [row] in a grid of [gridCols] x [gridRows]) to normalized + * target center ([targetX], [targetY]) for effortless drop-in use in [Thunder], [Earthquake], and [Flood]. + * + * Example: + * ```kotlin + * val (tx, ty) = OGEvents.gridTarget(col = 2, row = 3, gridCols = 5, gridRows = 5) + * OGEvents.Thunder(targetX = tx, targetY = ty, tileWidth = 0.16f, tileHeight = 0.16f) + * ``` + */ + fun gridTarget( + col: Int, + row: Int, + gridCols: Int, + gridRows: Int, + paddingFraction: Float = 0.05f, + ): Pair { + val usableW = (1f - 2f * paddingFraction).coerceAtLeast(0.1f) + val usableH = (1f - 2f * paddingFraction).coerceAtLeast(0.1f) + val cx = paddingFraction + (col + 0.5f) * (usableW / gridCols.coerceAtLeast(1)) + val cy = paddingFraction + (row + 0.5f) * (usableH / gridRows.coerceAtLeast(1)) + return cx to cy + } + + /** + * Build an anime-grade **Thunder & Lightning Storm** movie stage. + */ + fun thunderStage( + width: Int = 1600, + height: Int = 900, + durationMs: Long = 5000L, + fps: Int = 30, + ): OGStage { + val layers = ArrayList(8) + + // Seismic camera trauma track on strike (650ms) + val thunderShakeX = OGStages.track( + 0L to 0f, 600L to 0f, + 650L to 0.016f, 700L to -0.014f, 750L to 0.011f, + 810L to -0.008f, 880L to 0.005f, 960L to -0.003f, 1050L to 0f, + ) + val thunderShakeY = OGStages.track( + 0L to 0f, 600L to 0f, + 650L to -0.020f, 710L to 0.016f, 760L to -0.012f, + 820L to 0.007f, 890L to -0.004f, 980L to 0f, + ) + + // 1. Storm sky backdrop + val skyScene = OGSceneSpec( + shapes = listOf( + OGPlacedShapeSpec( + points = listOf(OGPointSpec(0f, 0f), OGPointSpec(1f, 0f), OGPointSpec(1f, 1f), OGPointSpec(0f, 1f)), + label = "tempest_sky", + gradient = OGFillSpec( + type = "linear", + from = OGPointSpec(0.5f, 0f), + to = OGPointSpec(0.5f, 1f), + stops = listOf(OGStopSpec(0f, "#020409"), OGStopSpec(0.65f, "#081024"), OGStopSpec(1f, "#122040")), + ), + ), + ), + background = "#020409", + version = 4, + ) + layers += OGStages.scene( + id = "sky", + scene = skyScene, + x = thunderShakeX, + y = thunderShakeY, + scale = OGStages.eased(OGEasing.EASE_OUT, 0L to 1f, durationMs to 1.05f), + ) + + // 2. Ambient Sky Flash (Impact Frame) - strobes bright white-cyan on strike + layers += OGStages.solid( + id = "sky_flash", + color = "#E1F5FE", + opacity = OGStages.track( + 0L to 0f, 600L to 0f, + 650L to 0.95f, 720L to 0.20f, + 760L to 0.85f, 980L to 0f, + ), + ) + + // 3. Main Forked Lightning Bolt (strikes at 650ms) + layers += OGStages.lightning( + id = "bolt", + spec = OGLightningSpec( + x1 = 0.62f, y1 = 0.02f, x2 = 0.50f, y2 = 0.68f, + width = 0.015f, jaggedness = 0.18f, detail = 7, branches = 6, branchLength = 0.42f, + color = "#90CAF9", coreColor = "#FFFFFF", glow = 1f, + strikeMs = 120f, decayMs = 380f, gapMs = 2800f, loop = true, seed = 42, + ), + blend = OGBlendMode.PLUS, + ).copy( + startMs = 600L, + x = thunderShakeX, + y = thunderShakeY, + opacity = OGStages.track(600L to 0f, 650L to 1f, 1200L to 1f, 1400L to 0f), + ) + + // 4. Ground Contact Sparks + layers += OGStages.particles( + id = "sparks", + spec = OGParticles.SPARKS.copy( + maxParticles = 120, burst = 80, emissionRate = 0f, + x = 0.50f, y = 0.68f, speed = 0.65f, speedJitter = 0.6f, + gravityY = 0.55f, lifetimeMs = 900f, + ), + startMs = 650L, + blend = OGBlendMode.PLUS, + ).copy(x = thunderShakeX, y = thunderShakeY) + + // 5. Airburst Ground Shockwave + layers += OGStages.shockwave( + id = "shockwave", + spec = OGShockwave.AIRBURST.copy(maxRadius = 0.42f, ringWidth = 0.024f), + originX = 0.50f, originY = 0.68f, + progress = OGStages.eased(OGEasing.EASE_OUT, 650L to 0f, 1350L to 1f), + startMs = 650L, + x = thunderShakeX, + y = thunderShakeY, + opacity = OGStages.track(650L to 0.95f, 1350L to 0f), + ) + + // 6. Torrential Driving Rain + layers += OGStages.particles( + id = "rain", + spec = OGParticles.RAIN.copy( + maxParticles = 500, emissionRate = 320f, speed = 1.6f, gravityY = 1.1f, + angleDeg = 105f, spreadDeg = 8f, startColor = "#B0BEC5", startAlpha = 0.8f, + ), + opacity = OGStages.eased(OGEasing.EASE_OUT, 0L to 0f, 400L to 0.85f), + ) + + // 7. Styled Title Card: THUNDER + layers += OGStages.text( + id = "title", + text = "THUNDER", + size = 0.22f, + color = "#E3F2FD", + bold = true, + style = OGStyleSpec( + ops = listOf( + OGStyleOp(op = "subdivide", detail = 4), + OGStyleOp(op = "boil", amplitude = 0.02f, boilFps = 10f), + ), + ), + opacity = OGStages.track( + 0L to 0f, 700L to 0f, 850L to 1f, + (durationMs - 600L) to 1f, durationMs to 0f, + ), + x = thunderShakeX, + y = thunderShakeY, + scale = OGStages.eased(OGEasing.EASE_OUT, 700L to 1.18f, 950L to 1f), + ) + + return OGStage.of( + *layers.toTypedArray(), + width = width, + height = height, + durationMs = durationMs, + fps = fps, + background = "#020409", + ) + } + + /** + * Build an anime-grade **Earthquake Cataclysm** movie stage. + */ + fun earthquakeStage( + width: Int = 1600, + height: Int = 900, + durationMs: Long = 5000L, + fps: Int = 30, + ): OGStage { + val layers = ArrayList(12) + + // 1. Tectonic Canyon Backdrop + val earthScene = OGSceneSpec( + shapes = listOf( + OGPlacedShapeSpec( + points = listOf(OGPointSpec(0f, 0f), OGPointSpec(1f, 0f), OGPointSpec(1f, 0.65f), OGPointSpec(0f, 0.65f)), + label = "sky", + gradient = OGFillSpec( + type = "linear", + from = OGPointSpec(0.5f, 0f), + to = OGPointSpec(0.5f, 0.65f), + stops = listOf(OGStopSpec(0f, "#120B08"), OGStopSpec(1f, "#3E2723")), + ), + ), + OGPlacedShapeSpec( + points = listOf(OGPointSpec(0f, 0.60f), OGPointSpec(0.35f, 0.52f), OGPointSpec(0.7f, 0.58f), OGPointSpec(1f, 0.50f), OGPointSpec(1f, 1f), OGPointSpec(0f, 1f)), + label = "far_ridge", + fill = "#2B1A14", + smoothing = 0.6f, + ), + OGPlacedShapeSpec( + points = listOf(OGPointSpec(0f, 0.72f), OGPointSpec(0.45f, 0.68f), OGPointSpec(0.8f, 0.74f), OGPointSpec(1f, 0.70f), OGPointSpec(1f, 1f), OGPointSpec(0f, 1f)), + label = "near_ground", + fill = "#1A0F0A", + smoothing = 0.5f, + ), + ), + background = "#120B08", + version = 4, + ) + + // Two-phase seismic shake: P-wave micro-jitter (100–400ms) then S-wave rupture (400–1800ms). + // ALL layers use the same shake tracks so the entire frame moves together like a real camera. + val shakeX = OGStages.track( + 0L to 0f, + // P-wave: quick high-frequency tremor before the rupture + 100L to 0.004f, 140L to -0.004f, 180L to 0.005f, 220L to -0.004f, + 260L to 0.005f, 300L to -0.003f, 340L to 0.004f, 380L to -0.003f, + // S-wave: violent low-frequency jolt + 400L to 0.012f, 480L to -0.015f, 560L to 0.018f, + 640L to -0.016f, 740L to 0.014f, 860L to -0.010f, 1050L to 0.007f, + 1350L to -0.004f, 1800L to 0f, + ) + val shakeY = OGStages.track( + 0L to 0f, + // P-wave + 110L to -0.003f, 150L to 0.003f, 190L to -0.004f, 230L to 0.003f, + 270L to -0.003f, 310L to 0.004f, 350L to -0.003f, 390L to 0.003f, + // S-wave + 420L to -0.018f, 500L to 0.022f, 580L to -0.019f, + 660L to 0.015f, 760L to -0.012f, 880L to 0.008f, 1100L to -0.005f, + 1400L to 0f, + ) + + layers += OGStages.scene( + id = "earth_backdrop", + scene = earthScene, + x = shakeX, + y = shakeY, + scale = OGStages.eased(OGEasing.EASE_OUT, 0L to 1f, durationMs to 1.06f), + ) + + // 2. Magma fissure thermal underglow — amber glow beneath the crack line + layers += OGStages.solid( + id = "magma_glow", + color = "#BF360C", + opacity = OGStages.track(0L to 0f, 400L to 0f, 700L to 0.18f, 2500L to 0.22f, durationMs to 0f), + ).copy(blend = OGBlendMode.PLUS) + + // 3. Propagating Ground Fractures (crack arms grow 400ms→1400ms) — shakes with the camera + layers += OGStages.crack( + id = "cracks", + spec = OGCrack.GROUND, + originX = 0.50f, originY = 0.72f, + progress = OGStages.eased(OGEasing.EASE_OUT, 400L to 0f, 1400L to 1f), + startMs = 400L, + x = shakeX, + y = shakeY, + opacity = OGStages.track(400L to 0f, 550L to 1f, 3500L to 1f, durationMs to 0f), + ) + + // 4. Ground-roll Shockwave Oval (expands 400ms→1200ms) — shakes with the camera + layers += OGStages.shockwave( + id = "ground_roll", + spec = OGShockwave.GROUND, + originX = 0.50f, originY = 0.72f, + progress = OGStages.eased(OGEasing.EASE_OUT, 400L to 0f, 1200L to 1f), + startMs = 400L, + x = shakeX, + y = shakeY, + opacity = OGStages.track(400L to 0.9f, 1200L to 0f), + ) + + // 5. Heavy Stone Debris — large chunks falling down (gravityY positive = downward) + val debrisSpec = OGParticleSpec( + name = "stone_debris", + shape = "square", + maxParticles = 80, + burst = 60, emissionRate = 20f, + lifetimeMs = 1800f, + x = 0.50f, y = 0.72f, spawnRadius = 0.30f, + angleDeg = 280f, spreadDeg = 80f, + speed = 0.55f, speedJitter = 0.7f, + gravityY = 0.50f, drag = 0.10f, + startSize = 0.022f, endSize = 0.010f, sizeJitter = 0.7f, + startColor = "#6D4C41", endColor = "#1A0F0A", + startAlpha = 0.95f, endAlpha = 0f, seed = 77, + ) + layers += OGStages.particles( + id = "debris", + spec = debrisSpec, + startMs = 400L, + opacity = OGStages.track(400L to 0f, 500L to 1f, 2000L to 1f, durationMs to 0f), + ).copy(x = shakeX, y = shakeY) + + // 6. Fine Rising Rock Dust — lighter motes float upward + val dustSpec = OGParticleSpec( + name = "earth_dust", + shape = "circle", + maxParticles = 180, + emissionRate = 65f, + lifetimeMs = 2800f, + x = 0.50f, y = 0.82f, spawnRadius = 0.40f, + angleDeg = 270f, spreadDeg = 75f, + speed = 0.16f, speedJitter = 0.6f, + gravityY = -0.035f, drag = 0.25f, + startSize = 0.018f, endSize = 0.035f, sizeJitter = 0.6f, + startColor = "#D7CCC8", endColor = "#5D4037", + startAlpha = 0.75f, endAlpha = 0f, seed = 88, + ) + layers += OGStages.particles( + id = "dust", + spec = dustSpec, + startMs = 400L, + opacity = OGStages.track(400L to 0f, 650L to 1f, 3200L to 1f, durationMs to 0f), + ).copy(x = shakeX, y = shakeY) + + // 7. Styled Title Card: EARTHQUAKE — shakes with the camera + layers += OGStages.text( + id = "title", + text = "EARTHQUAKE", + size = 0.18f, + color = "#FFE082", + bold = true, + style = OGStyleSpec( + ops = listOf( + OGStyleOp(op = "roughen", amplitude = 0.03f, detail = 4), + OGStyleOp(op = "boil", amplitude = 0.02f, boilFps = 12f), + ), + ), + opacity = OGStages.track( + 0L to 0f, 500L to 0f, 700L to 1f, + (durationMs - 600L) to 1f, durationMs to 0f, + ), + x = shakeX, + y = shakeY, + scale = OGStages.eased(OGEasing.EASE_OUT, 500L to 1.22f, 850L to 1f), + ) + + return OGStage.of( + *layers.toTypedArray(), + width = width, + height = height, + durationMs = durationMs, + fps = fps, + background = "#120B08", + ) + } + + /** + * Build an anime-grade **Flood Inundation & Surging Water** movie stage. + */ + fun floodStage( + width: Int = 1600, + height: Int = 900, + durationMs: Long = 5000L, + fps: Int = 30, + ): OGStage { + val layers = ArrayList(8) + + // Tidal perspective swell track (gentle vertical undulation of the waterline) + val swellY = OGStages.track( + 0L to 0f, 1200L to 0.008f, 2500L to -0.006f, 3800L to 0.005f, durationMs to 0f, + ) + + // 1. Dark Gloomy Horizon Backdrop + val floodSky = OGSceneSpec( + shapes = listOf( + OGPlacedShapeSpec( + points = listOf(OGPointSpec(0f, 0f), OGPointSpec(1f, 0f), OGPointSpec(1f, 0.65f), OGPointSpec(0f, 0.65f)), + label = "stormy_horizon", + gradient = OGFillSpec( + type = "linear", + from = OGPointSpec(0.5f, 0f), + to = OGPointSpec(0.5f, 0.65f), + stops = listOf(OGStopSpec(0f, "#06101E"), OGStopSpec(1f, "#102A45")), + ), + ), + ), + background = "#06101E", + version = 4, + ) + layers += OGStages.scene(id = "horizon", scene = floodSky, y = swellY) + + // 2. Background deep wave — dark navy, slow, higher waterline. Enters from below, rises. + // y animates from +0.35 (off-screen below) up to -0.15 (high water) over the stage duration. + layers += OGStages.wave( + id = "deep_wave", + spec = OGWave.FLOOD.copy( + surfaceY = 0.55f, amplitude = 0.07f, speed = 0.55f, + deepColor = "#0D233A", shallowColor = "#1A4A72", + ), + startMs = 0L, + y = OGStages.eased(OGEasing.EASE_IN_OUT, 0L to 0.35f, durationMs to -0.18f), + opacity = OGStages.eased(OGEasing.EASE_OUT, 0L to 0f, 600L to 0.80f), + ) + + // 3. Foreground surge wave — brighter teal, faster, lower waterline. Also rises. + layers += OGStages.wave( + id = "surge", + spec = OGWave.FLOOD.copy(surfaceY = 0.68f, amplitude = 0.11f, speed = 1.10f), + startMs = 200L, + y = OGStages.eased(OGEasing.EASE_IN_OUT, 200L to 0.20f, durationMs to -0.12f), + opacity = OGStages.eased(OGEasing.EASE_OUT, 200L to 0f, 700L to 0.88f), + ) + + // 4. Submersion tint — deepens as the water rises (ocean-floor murk) + layers += OGStages.solid( + id = "submersion_tint", + color = "#0D47A1", + opacity = OGStages.eased(OGEasing.EASE_IN_OUT, 0L to 0f, durationMs to 0.28f), + ).copy(blend = OGBlendMode.MULTIPLY) + + // 5. Whipped Foam Crest Spray Droplets + layers += OGStages.particles( + id = "spray", + spec = OGParticles.DROPLETS.copy( + maxParticles = 240, emissionRate = 95f, + x = 0.45f, y = 0.62f, spawnRadius = 0.50f, + angleDeg = 60f, spreadDeg = 45f, + speed = 0.42f, speedJitter = 0.5f, gravityY = 0.40f, + startColor = "#E1F5FE", endColor = "#81D4FA", + ), + opacity = OGStages.eased(OGEasing.EASE_OUT, 0L to 0f, 500L to 0.9f), + ) + + // 6. Driving Heavy Torrential Rain + layers += OGStages.particles( + id = "rain", + spec = OGParticles.RAIN.copy( + maxParticles = 400, emissionRate = 260f, speed = 1.4f, gravityY = 0.9f, + angleDeg = 100f, spreadDeg = 10f, startAlpha = 0.75f, + ), + ) + + // 7. Styled Title Card: FLOOD + layers += OGStages.text( + id = "title", + text = "FLOOD", + size = 0.22f, + color = "#80D8FF", + bold = true, + style = OGStyleSpec( + ops = listOf( + OGStyleOp(op = "wave", amplitude = 0.03f, waves = 4, speed = 1.8f), + OGStyleOp(op = "boil", amplitude = 0.015f, boilFps = 8f), + ), + ), + opacity = OGStages.track( + 0L to 0f, 400L to 0f, 650L to 1f, + (durationMs - 600L) to 1f, durationMs to 0f, + ), + scale = OGStages.eased(OGEasing.EASE_OUT, 400L to 1.15f, 750L to 1f), + ) + + return OGStage.of( + *layers.toTypedArray(), + width = width, + height = height, + durationMs = durationMs, + fps = fps, + background = "#06101E", + ) + } + + // ── Drop-in Composable Event Views ──────────────────────────────────────────────────────────── + + /** + * Drop-in anime-grade **Thunderstorm Event** view. + * Features ambient screen flash, forked lightning bolt, airburst shockwave, and rain. + * When [targetX] and [targetY] are provided, the lightning bolt and anime slash ribbon + * target that exact cell, exploding radial letters/sparks, scorching the tile, and lingering + * with an afterimage ghost bolt. + */ + @Composable + fun Thunder( + modifier: Modifier = Modifier, + targetX: Float = 0.5f, + targetY: Float = 0.5f, + tileWidth: Float = 0.125f, + tileHeight: Float = 0.125f, + playing: Boolean = true, + durationMs: Long = 3400L, + seed: Int = 42, + tileContent: (@Composable () -> Unit)? = null, + onDone: (() -> Unit)? = null, + ) { + val progress = remember(seed) { Animatable(0f) } + var striking by remember(seed) { mutableStateOf(true) } + var burning by remember(seed) { mutableStateOf(false) } + var afterBoltPlaying by remember(seed) { mutableStateOf(false) } + + val cx = targetX + val cy = targetY + + val tImpact = (180f / durationMs).coerceIn(0.03f, 0.20f) + val tBurn = (980f / durationMs).coerceIn(0.20f, 0.40f) + + // Randomized organic burn pattern on the struck tile + val burnData = remember(seed, tileWidth, tileHeight) { + val rng = Random(seed) + val soX = (rng.nextFloat() - 0.5f) * (tileWidth * 0.18f) + val soY = (rng.nextFloat() - 0.5f) * (tileHeight * 0.18f) + val strikeOffset = Offset(soX, soY) + + val minDim = kotlin.math.min(tileWidth, tileHeight) + + // Inner jagged charred core (14 vertices) + val numPts = 14 + val scorchPts = mutableListOf() + for (i in 0 until numPts) { + val angle = i * (2f * PI.toFloat() / numPts) + (rng.nextFloat() - 0.5f) * 0.22f + val r = (0.28f + rng.nextFloat() * 0.24f) * minDim + scorchPts.add(Offset(soX + cos(angle) * r, soY + sin(angle) * r)) + } + + // Outer soft soot perimeter (12 vertices) + val numSoot = 12 + val sootPts = mutableListOf() + for (i in 0 until numSoot) { + val angle = i * (2f * PI.toFloat() / numSoot) + (rng.nextFloat() - 0.5f) * 0.25f + val r = (0.42f + rng.nextFloat() * 0.26f) * minDim + sootPts.add(Offset(soX + cos(angle) * r, soY + sin(angle) * r)) + } + + // Radiating glowing ember fracture veins + val numCracks = 3 + (rng.nextInt(3)) + val cracks = mutableListOf>() + for (c in 0 until numCracks) { + val baseAng = c * (2f * PI.toFloat() / numCracks) + (rng.nextFloat() - 0.5f) * 0.40f + val crackPts = mutableListOf(strikeOffset) + var cur = strikeOffset + val segs = 3 + val segLen = minDim * (0.12f + rng.nextFloat() * 0.08f) + var curAng = baseAng + for (s in 1..segs) { + curAng += (rng.nextFloat() - 0.5f) * 0.55f + cur = Offset(cur.x + cos(curAng) * segLen, cur.y + sin(curAng) * segLen) + crackPts.add(cur) + } + cracks.add(crackPts) + } + + // Scorch pit spots + val numSpots = 4 + rng.nextInt(3) + val spots = mutableListOf>() + for (sp in 0 until numSpots) { + val spAng = rng.nextFloat() * 2f * PI.toFloat() + val spR = rng.nextFloat() * minDim * 0.30f + val spPt = Offset(soX + cos(spAng) * spR, soY + sin(spAng) * spR) + val spRadius = minDim * (0.032f + rng.nextFloat() * 0.040f) + spots.add(spPt to spRadius) + } + + ThunderBurnData( + strikeOffset = strikeOffset, + scorchPolygon = scorchPts, + sootPolygon = sootPts, + emberCracks = cracks, + scorchSpots = spots, + ) + } + + // IMPACT — main bolt: massive, blinding, branching down to the cell with needle-point taper and impact flash + val mainBolt = remember(seed, cx, cy) { + OGLightningSpec( + x1 = cx + 0.015f, y1 = 0f, x2 = cx, y2 = cy, + width = 0.032f, jaggedness = 0.22f, detail = 9, branches = 12, branchLength = 0.48f, + color = "#81D4FA", coreColor = "#FFFFFF", glow = 3.5f, glowColor = "#00E5FF", + strikeMs = 140f, decayMs = 560f, gapMs = 10_000f, loop = false, seed = seed, + taper = true, flashRadius = 0.20f, + ) + } + + // IMPACT — letters explode radially from the struck tile (on-brand for word game & action FX) + val letterMeasurer = rememberTextMeasurer() + val impactLetters = remember(seed) { + val rng = Random(seed + 77) + val pool = "AEIOURSTLNBCDFGM" + List(12) { i -> + val baseAngle = i * (360f / 12f) + val angle = (baseAngle + rng.nextFloat() * 22f - 11f) * (PI.toFloat() / 180f) + Triple(pool[rng.nextInt(pool.length)].toString(), angle, rng.nextFloat()) + } + } + val impactSparks = remember(seed, cx, cy) { + OGParticles.SPARKS.copy( + burst = 60, emissionRate = 0f, maxParticles = 60, + x = cx, y = cy, spawnRadius = 0.012f, + angleDeg = 270f, spreadDeg = 360f, speed = 1.05f, speedJitter = 0.78f, + lifetimeMs = 780f, gravityY = 0.20f, + startColor = "#FFF176", endColor = "#FF6F00", endAlpha = 0f, seed = seed, + ) + } + val impactSparkles = remember(seed, cx, cy) { + OGParticles.SPARKLES.copy( + burst = 22, emissionRate = 0f, maxParticles = 22, + x = cx, y = cy, spawnRadius = 0.010f, + speed = 0.38f, speedJitter = 0.72f, + lifetimeMs = 520f, gravityY = 0.05f, + startColor = "#FFFFFF", endColor = "#FFD600", endAlpha = 0f, + spinDeg = 600f, spinJitter = 1f, seed = seed + 9, + ) + } + val preChargeSparks = remember(seed, cx) { + OGParticles.EMBERS.copy( + burst = 0, emissionRate = 22f, maxParticles = 30, + x = cx, y = 0.88f, spawnRadius = 0.38f, + angleDeg = 270f, spreadDeg = 55f, + speed = 0.18f, speedJitter = 0.6f, gravityY = -0.08f, + lifetimeMs = 900f, drag = 0.05f, + startColor = "#FFD600", endColor = "#FFF9C4", endAlpha = 0f, + startSize = 0.007f, endSize = 0.003f, sizeJitter = 0.5f, seed = seed + 41, + ) + } + val burnFlame = remember(seed) { + val rng = Random(seed) + val tongues = 6 + (seed % 3) + val h = 0.80f + ((seed % 7) - 3) * 0.02f + val sway = 0.06f + ((seed % 5) - 2) * 0.01f + OGFlameSpec( + name = "tile_fire", + coreColor = "#FFF59D", + tipColor = "#FF3D00", + tongues = tongues, + height = h, + heightJitter = 0.35f, + width = 0.36f, + sway = sway, + swaySpeed = 1.3f, + flicker = 0.40f, + flickerSpeed = 2.6f, + octaves = 3, + opacity = 0.96f, + baseY = 0.88f, + seed = seed + 13, + ) + } + val afterBolt = remember(seed, cx, cy) { + OGLightningSpec( + x1 = cx + 0.02f, y1 = 0f, x2 = cx, y2 = cy, + width = 0.012f, jaggedness = 0.20f, detail = 8, branches = 5, branchLength = 0.35f, + color = "#FFF9C4", coreColor = "#FFF9C4", glow = 1.2f, glowColor = "#FFFDE7", + strikeMs = 10f, decayMs = 480f, gapMs = 10_000f, loop = false, seed = seed + 3, + ) + } + val rainParticles = remember { + OGParticles.RAIN.copy(maxParticles = 250, emissionRate = 180f, speed = 1.4f) + } + + LaunchedEffect(seed, playing) { + if (!playing) return@LaunchedEffect + striking = true + delay(980L) + striking = false + burning = true + delay(200L) + afterBoltPlaying = true + delay((durationMs - 1180L).coerceAtLeast(0L)) + burning = false + afterBoltPlaying = false + onDone?.invoke() + } + LaunchedEffect(seed, playing) { + if (!playing) return@LaunchedEffect + progress.animateTo(1f, tween(durationMs.toInt(), easing = LinearEasing)) + } + + val p = progress.value + + // Seismic camera trauma at strike impact (quadratic punch) + val impactP = nfSmooth(tImpact, tImpact + 0.16f, p) + val impactShake = ((1f - impactP) * (if (p >= tImpact && p < tImpact + 0.20f) 1f else 0f)).coerceIn(0f, 1f) + val shakeTrauma = impactShake * impactShake + val shakeJoltX = sin(p * 320f) * 28f * shakeTrauma + val shakeJoltY = cos(p * 260f) * 18f * shakeTrauma + val shakeTilt = sin(p * 190f) * 3.5f * shakeTrauma + + Box( + modifier.fillMaxSize().graphicsLayer { + alpha = 1f - nfSmooth(0.92f, 1f, p) + translationX = shakeJoltX + translationY = shakeJoltY + rotationZ = shakeTilt + }.clipToBounds(), + ) { + // Torrential background rain + OGParticleView(rainParticles, Modifier.fillMaxSize(), playing = playing) + + // PRE-IMPACT: sky floods golden + Box( + Modifier.fillMaxSize().graphicsLayer { + alpha = nfSmooth(0f, tImpact * 0.55f, p) * + (1f - nfSmooth(tImpact - 0.008f, tImpact + 0.002f, p)) * 0.12f + }.background(Color(0xFFFFD600)) + ) + + // IMPACT: ONE-FRAME pure-white freeze-frame flash + Box( + Modifier.fillMaxSize().graphicsLayer { + alpha = (nfSmooth(tImpact - 0.004f, tImpact + 0.002f, p) * + (1f - nfSmooth(tImpact + 0.008f, tImpact + 0.060f, p))).coerceIn(0f, 1f) + }.background(Color(0xFFFFFFFF)) + ) + + // BURN: faint warm orange wash as the tile blazes + Box( + Modifier.fillMaxSize().graphicsLayer { + alpha = nfSmooth(tBurn, tBurn + 0.10f, p) * 0.06f + }.background(Color(0xFFFF8C00)) + ) + + // Upward static pre-charge sparks + OGParticleView(preChargeSparks, Modifier.fillMaxSize(), playing = p < tImpact + 0.04f) + + // IMPACT: lightning bolt + sparks + sparkles (pure fractal bolt, no thick worm ribbon) + OGLightningView(mainBolt, Modifier.fillMaxSize(), playing = striking) + OGParticleView(impactSparks, Modifier.fillMaxSize(), playing = striking) + OGParticleView(impactSparkles, Modifier.fillMaxSize(), playing = striking) + + // Overexposure flash bloom + Box( + Modifier.fillMaxSize().graphicsLayer { + alpha = (nfSmooth(tImpact, tImpact + 50f / durationMs, p) * + (1f - nfSmooth(tImpact + 50f / durationMs, tImpact + 150f / durationMs, p))) + .coerceIn(0f, 1f) + }.background(Color.White) + ) + + // Shockwave rings & radial letter bursts + Canvas(Modifier.fillMaxSize()) { + val ring1 = nfSmooth(tImpact, tImpact + 380f / durationMs, p) + val ring2 = nfSmooth(tImpact, tImpact + 700f / durationMs, p) + drawOGShockwaveFrame(OGShockwaveSpec(color = "#FFF59D", maxRadius = 0.22f, ringWidth = 0.018f, alpha = 0.95f), cx, cy, ring1) + drawOGShockwaveFrame(OGShockwaveSpec(color = "#FFE082", maxRadius = 0.44f, ringWidth = 0.026f, alpha = 0.72f), cx, cy, ring2) + + // ── Struck Tile Electrocution & Scorching ───────────────────────── + val strikeOccurred = p >= tImpact + if (strikeOccurred) { + val tW = tileWidth * size.width + val tH = tileHeight * size.height + val tLeft = (cx - tileWidth / 2f) * size.width + val tTop = (cy - tileHeight / 2f) * size.height + + // 1. Dielectric Ground Streamers: hairline fractal discharge arcs (no worms) + val groundArcP = nfSmooth(tImpact, tImpact + 0.18f, p) + if (groundArcP in 0.01f..0.98f) { + val arcFade = (1f - groundArcP) * (1f - groundArcP) + val arcRng = Random(seed + (p * 80).toInt()) + val groundArms = 12 + for (a in 0 until groundArms) { + val baseAngle = a * (2f * PI.toFloat() / groundArms) + (arcRng.nextFloat() - 0.5f) * 0.35f + val arcLen = size.minDimension * (0.12f + arcRng.nextFloat() * 0.16f) * groundArcP + val arcPath = Path().apply { + moveTo(cx * size.width, cy * size.height) + var currX = cx * size.width + var currY = cy * size.height + val segs = 6 + for (s in 1..segs) { + val frac = s.toFloat() / segs + val angDev = (arcRng.nextFloat() - 0.5f) * 0.9f + val r = arcLen * frac + currX = cx * size.width + cos(baseAngle + angDev) * r + currY = cy * size.height + sin(baseAngle + angDev) * r + lineTo(currX, currY) + } + } + // Cyan bloom halo + razor white-hot needle core + drawPath( + arcPath, + color = Color(0xFF00E5FF).copy(alpha = arcFade * 0.85f), + style = Stroke(width = 2.8f, cap = StrokeCap.Round), + blendMode = BlendMode.Plus, + ) + drawPath( + arcPath, + color = Color.White.copy(alpha = arcFade * 0.98f), + style = Stroke(width = 1.0f, cap = StrokeCap.Round), + blendMode = BlendMode.Plus, + ) + } + } + + // 2. Tile Body: White-hot impact -> Charred charcoal body -> Scorched ember glow with organic scorch + val scorchAlpha = (1f - nfSmooth(0.85f, 1f, p)).coerceIn(0f, 1f) + val tileJitterX = if (p < tImpact + 0.12f) (Random(seed + (p * 200).toInt()).nextFloat() - 0.5f) * 8f else 0f + val tileJitterY = if (p < tImpact + 0.12f) (Random(seed + 1 + (p * 200).toInt()).nextFloat() - 0.5f) * 8f else 0f + + withTransform({ + translate(tileJitterX, tileJitterY) + }) { + // Cast shadow beneath charred tile + drawRoundRect( + color = Color(0x99000000), + topLeft = Offset(tLeft + 4f, tTop + 6f), + size = Size(tW, tH), + cornerRadius = CornerRadius(8f), + ) + // Charred black tile body + drawRoundRect( + color = Color(0xFF141414).copy(alpha = scorchAlpha), + topLeft = Offset(tLeft, tTop), + size = Size(tW, tH), + cornerRadius = CornerRadius(8f), + ) + + // Organic randomized scorch pattern on the tile surface + val tileMidX = tLeft + tW * 0.5f + val tileMidY = tTop + tH * 0.5f + val sScaleX = size.width + val sScaleY = size.height + + // 2a. Outer soft soot perimeter + if (burnData.sootPolygon.size >= 3) { + val sootPath = Path().apply { + val first = burnData.sootPolygon[0] + moveTo(tileMidX + first.x * sScaleX, tileMidY + first.y * sScaleY) + for (i in 1 until burnData.sootPolygon.size) { + val pt = burnData.sootPolygon[i] + lineTo(tileMidX + pt.x * sScaleX, tileMidY + pt.y * sScaleY) + } + close() + } + drawPath(sootPath, color = Color(0xFF26140B).copy(alpha = 0.70f * scorchAlpha)) + } + + // 2b. Charred black core + if (burnData.scorchPolygon.size >= 3) { + val charPath = Path().apply { + val first = burnData.scorchPolygon[0] + moveTo(tileMidX + first.x * sScaleX, tileMidY + first.y * sScaleY) + for (i in 1 until burnData.scorchPolygon.size) { + val pt = burnData.scorchPolygon[i] + lineTo(tileMidX + pt.x * sScaleX, tileMidY + pt.y * sScaleY) + } + close() + } + drawPath(charPath, color = Color(0xFF0C0907).copy(alpha = 0.92f * scorchAlpha)) + } + + // 2c. Dark blast pits with ember rims + for ((spotOffset, spotRadius) in burnData.scorchSpots) { + val spX = tileMidX + spotOffset.x * sScaleX + val spY = tileMidY + spotOffset.y * sScaleY + val spR = spotRadius * sScaleX + drawCircle(Color(0xFF0A0705).copy(alpha = 0.95f * scorchAlpha), radius = spR, center = Offset(spX, spY)) + drawCircle(Color(0xFFFF9100).copy(alpha = 0.75f * scorchAlpha), radius = spR, center = Offset(spX, spY), style = Stroke(width = 1.2f)) + } + + // 2d. Glowing ember fracture veins + for (crack in burnData.emberCracks) { + if (crack.size >= 2) { + for (ci in 0 until crack.size - 1) { + val cp1 = Offset(tileMidX + crack[ci].x * sScaleX, tileMidY + crack[ci].y * sScaleY) + val cp2 = Offset(tileMidX + crack[ci + 1].x * sScaleX, tileMidY + crack[ci + 1].y * sScaleY) + drawLine(Color(0xFFFF5722).copy(alpha = 0.85f * scorchAlpha), cp1, cp2, strokeWidth = 2.8f, cap = StrokeCap.Round) + drawLine(Color(0xFFFFD54F).copy(alpha = 0.95f * scorchAlpha), cp1, cp2, strokeWidth = 1.0f, cap = StrokeCap.Round) + } + } + } + + // Scorched ember underglow rim + drawRoundRect( + color = Color(0xFFFF6D00).copy(alpha = scorchAlpha * 0.75f), + topLeft = Offset(tLeft, tTop), + size = Size(tW, tH), + cornerRadius = CornerRadius(8f), + style = Stroke(width = 3.5f), + ) + + // Blinding white-hot flash over the tile immediately upon strike + val whiteFlashAlpha = (1f - nfSmooth(tImpact, tImpact + 0.08f, p)).coerceIn(0f, 1f) + if (whiteFlashAlpha > 0.01f) { + drawRoundRect( + color = Color.White.copy(alpha = whiteFlashAlpha), + topLeft = Offset(tLeft, tTop), + size = Size(tW, tH), + cornerRadius = CornerRadius(8f), + ) + // Radial plasma flare star + val flareR = tW * 1.8f * whiteFlashAlpha + drawCircle(Color(0xFF00E5FF).copy(alpha = whiteFlashAlpha * 0.8f), radius = flareR * 1.4f, center = Offset(cx * size.width, cy * size.height), blendMode = BlendMode.Plus) + drawCircle(Color.White.copy(alpha = whiteFlashAlpha), radius = flareR * 0.6f, center = Offset(cx * size.width, cy * size.height), blendMode = BlendMode.Plus) + for (sp in 0..3) { + val ang = sp * (PI.toFloat() / 2f) + val sx = cos(ang) * flareR * 2.5f + val sy = sin(ang) * flareR * 2.5f + drawLine(Color.White.copy(alpha = whiteFlashAlpha), Offset(cx * size.width, cy * size.height), Offset(cx * size.width + sx, cy * size.height + sy), strokeWidth = 2f, blendMode = BlendMode.Plus) + } + } + } + } + + val letterP = nfSmooth(tImpact, tImpact + 280f / durationMs, p) + if (letterP > 0f) { + val letterAlpha = (1f - nfSmooth(tImpact + 70f / durationMs, tImpact + 280f / durationMs, p)).coerceIn(0f, 1f) + impactLetters.forEach { (letter, angle, jitter) -> + val dist = size.minDimension * (0.11f + jitter * 0.10f) * letterP + val lx = cx * size.width + cos(angle) * dist + val ly = cy * size.height + sin(angle) * dist + if (letterAlpha > 0.01f) { + val measured = letterMeasurer.measure( + text = letter, + style = TextStyle(fontSize = 17.sp, fontWeight = FontWeight.Black, color = Color(0xFFFFF9C4)), + ) + withTransform({ + translate(lx, ly) + rotate(degrees = jitter * 220f * letterP, pivot = Offset.Zero) + }) { + drawText( + textLayoutResult = measured, + topLeft = Offset(-measured.size.width / 2f, -measured.size.height / 2f), + alpha = letterAlpha, + ) + } + } + } + } + } + + // BURN: afterimage ghost bolt + OGLightningView(afterBolt, Modifier.fillMaxSize(), playing = afterBoltPlaying) + + // BURN: roaring campfire flame erupting from tile center — sized to the tile, organic base + BoxWithConstraints(Modifier.fillMaxSize()) { + val tW = maxWidth * tileWidth + val tH = maxHeight * tileHeight + val flameW = tW * 1.45f // nicely engulfs the single struck tile without flooding the board + val flameH = tH * 2.8f + Box( + Modifier + .offset( + x = maxWidth * cx - flameW / 2f, + y = maxHeight * cy + tH * 0.45f - flameH * 0.88f, + ) + .width(flameW).height(flameH) + .graphicsLayer { alpha = nfSmooth(tBurn, tBurn + 0.08f, p) * (1f - nfSmooth(0.82f, 0.96f, p)) }, + ) { + OGFlameView(burnFlame, Modifier.fillMaxSize(), playing = burning) + } + } + } + } + + /** + * Drop-in anime-grade **Earthquake Event** view. + * Features: + * - Multi-fault brittle fractures scattering across random regions of the board. + * - Primary tectonic rupture cutting into [targetX, targetY] and tearing open a tile-sized hole area. + * - Brand new 3D perspective subterranean chasm: deep pitch-black void, vertical rock cliff face, + * illuminated granite bevel lips, and physical foreground rim occlusion. + * - Physical swallow flow: tile shudders, tilts, plunges down with gravity acceleration, shrinks in perspective, + * darkens into pure black as subterranean shadow engulfs it, and slips behind the front rock lip into the dark abyss. + * - Rising dust plumes, tumbling rock debris, and dual-phase seismic camera trauma. + */ + @Composable + fun Earthquake( + modifier: Modifier = Modifier, + targetX: Float = 0.5f, + targetY: Float = 0.5f, + tileWidth: Float = 0.125f, + tileHeight: Float = 0.125f, + playing: Boolean = true, + durationMs: Long = 4800L, + seed: Int = 100, + cameraShake: Boolean = false, + tileContent: (@Composable () -> Unit)? = null, + onDone: (() -> Unit)? = null, + ) { + val progress = remember(seed) { Animatable(0f) } + var rupturing by remember(seed) { mutableStateOf(false) } + + val cx = targetX + val cy = targetY + + // 1. Fault line & crack geometry: + // A single realistic seismic fault cuts across the board straight through (cx, cy). + // Under the selected tile, it ENLARGES along the exact direction of the fault line. + val faultGeometry = remember(seed, cx, cy, tileWidth) { + val rng = Random(seed) + + // Fault angle across the board: diagonal orientation + val baseAngle = if (rng.nextBoolean()) 36f else 144f + val faultAngleDeg = baseAngle + (rng.nextFloat() - 0.5f) * 16f + val faultRad = faultAngleDeg * (PI.toFloat() / 180f) + val uDir = Offset(cos(faultRad), sin(faultRad)) // along crack + val nDir = Offset(-sin(faultRad), cos(faultRad)) // perpendicular normal + + val numNodes = 28 + val faultNodes = mutableListOf() + val sSpan = 0.70f // normalized span across the board + + for (i in 0..numNodes) { + val t = i.toFloat() / numNodes + val s = -sSpan + t * (2f * sSpan) + val distCenter = kotlin.math.abs(s) + // Perpendicular jagged jitter, tapering smoothly near epicenter (cx, cy) + val jitterAmp = (0.007f + 0.011f * (distCenter / sSpan)) * (if (i % 2 == 0) 1f else -1f) + val jag = (rng.nextFloat() - 0.5f) * 0.006f + val pt = Offset(cx + uDir.x * s + nDir.x * (jitterAmp + jag), cy + uDir.y * s + nDir.y * (jitterAmp + jag)) + faultNodes.add(FaultNode(s = s, basePt = pt)) + } + + // Hairline secondary stress micro-cracks branching off the primary fault line + val branches = mutableListOf>() + val branchIndices = listOf(5, 8, 20, 23) + for ((bIdx, nodeIdx) in branchIndices.withIndex()) { + if (nodeIdx < faultNodes.size) { + val root = faultNodes[nodeIdx].basePt + val side = if (bIdx % 2 == 0) 1f else -1f + val bAngle = (faultAngleDeg + side * (38f + rng.nextFloat() * 12f)) * (PI.toFloat() / 180f) + val bDir = Offset(cos(bAngle), sin(bAngle)) + val bNormal = Offset(-sin(bAngle), cos(bAngle)) + val bPts = mutableListOf(root) + var cur = root + val segs = 3 + (bIdx % 2) + val bSegLen = 0.024f + for (s in 1..segs) { + val sDev = (rng.nextFloat() - 0.5f) * 0.005f + cur = Offset(cur.x + bDir.x * bSegLen + bNormal.x * sDev, cur.y + bDir.y * bSegLen + bNormal.y * sDev) + bPts.add(cur) + } + branches.add(bPts) + } + } + + // 3 to 4 auxiliary smaller cracks scattered across different parts of the board + val minorCracks = mutableListOf() + val candidateOrigins = listOf( + Offset(0.18f, 0.20f), + Offset(0.82f, 0.22f), + Offset(0.16f, 0.80f), + Offset(0.84f, 0.78f), + Offset(0.50f, 0.14f), + Offset(0.50f, 0.86f), + ) + + candidateOrigins.forEachIndexed { idx, origin -> + val dx = origin.x - cx + val dy = origin.y - cy + val dist = kotlin.math.sqrt(dx * dx + dy * dy) + if (dist >= 0.22f && minorCracks.size < 4) { + val jx = (rng.nextFloat() - 0.5f) * 0.08f + val jy = (rng.nextFloat() - 0.5f) * 0.08f + val sx = (origin.x + jx).coerceIn(0.08f, 0.92f) + val sy = (origin.y + jy).coerceIn(0.08f, 0.92f) + + var curAngle = (faultAngleDeg + (if (idx % 2 == 0) 32f else -32f) + (rng.nextFloat() - 0.5f) * 20f) * (PI.toFloat() / 180f) + val segCount = 4 + (idx % 3) + val segLen = 0.022f + rng.nextFloat() * 0.010f + val pts = mutableListOf(Offset(sx, sy)) + var cur = Offset(sx, sy) + val mBranches = mutableListOf>() + + for (s in 1..segCount) { + val dev = (rng.nextFloat() - 0.5f) * 0.50f + curAngle += dev + val nxt = Offset( + (cur.x + cos(curAngle) * segLen).coerceIn(0.04f, 0.96f), + (cur.y + sin(curAngle) * segLen).coerceIn(0.04f, 0.96f), + ) + pts.add(nxt) + + if (s == 2 && rng.nextFloat() < 0.75f) { + val bSide = if (rng.nextBoolean()) 40f else -40f + val bRad = curAngle + bSide * (PI.toFloat() / 180f) + val bPts = mutableListOf(nxt) + var bCur = nxt + val bSegs = 2 + for (bs in 1..bSegs) { + val bNxt = Offset( + (bCur.x + cos(bRad) * 0.018f).coerceIn(0.04f, 0.96f), + (bCur.y + sin(bRad) * 0.018f).coerceIn(0.04f, 0.96f), + ) + bPts.add(bNxt) + bCur = bNxt + } + mBranches.add(bPts) + } + cur = nxt + } + minorCracks.add(MinorCrack(pts, mBranches)) + } + } + + // Rift reach: chasm widens along the crack for 1.35x tile width on either side of epicenter + val riftReach = tileWidth * 1.35f + val riftHalfMax = tileWidth * 0.68f + + QuakeFaultData( + uDir = uDir, + nDir = nDir, + nodes = faultNodes, + branches = branches, + minorCracks = minorCracks, + riftReach = riftReach, + riftHalfMax = riftHalfMax, + ) + } + + // Rock dust motes erupting as the chasm splits + val stoneDust = remember(seed, cx, cy) { + OGParticles.EMBERS.copy( + burst = 25, emissionRate = 0f, maxParticles = 35, + x = cx, y = cy, spawnRadius = 0.05f, + angleDeg = 270f, spreadDeg = 90f, speed = 0.40f, speedJitter = 0.50f, + lifetimeMs = 800f, gravityY = 0.18f, + startColor = "#D7CCC8", endColor = "#5D4037", endAlpha = 0f, seed = seed + 3, + ) + } + + LaunchedEffect(seed, playing) { + if (!playing) return@LaunchedEffect + delay((durationMs * 0.32f).toLong()) + rupturing = true + delay((durationMs * 0.30f).toLong()) + rupturing = false + } + LaunchedEffect(seed, playing) { + if (!playing) return@LaunchedEffect + progress.animateTo(1f, tween(durationMs.toInt(), easing = LinearEasing)) + rupturing = false + onDone?.invoke() + } + + val p = progress.value + val fadeAlpha = (1f - nfSmooth(0.90f, 0.99f, p)).coerceIn(0f, 1f) + + // Camera tremor (only if requested) + val seismicTrauma = nfSmooth(0.08f, 0.28f, p) * (1f - nfSmooth(0.70f, 0.92f, p)) + val camJoltX = if (!cameraShake || seismicTrauma < 0.01f) 0f else sin(p * 70f * PI.toFloat()) * 4.5f * seismicTrauma + val camJoltY = if (!cameraShake || seismicTrauma < 0.01f) 0f else cos(p * 55f * PI.toFloat()) * 3.5f * seismicTrauma + + Box(modifier.fillMaxSize()) { + val originX = cx + val originY = cy + + val crackProp = nfSmooth(0.14f, 0.38f, p) + val riftOpen = nfSmooth(0.26f, 0.46f, p) + + // ── LAYER 1: Subterranean Chasm Abyss (BEHIND the falling tile) ─────────────── + Canvas(Modifier.fillMaxSize()) { + val bW = size.width + val bH = size.height + + if (riftOpen > 0.01f) { + val riftNodes = faultGeometry.nodes.filter { kotlin.math.abs(it.s) <= faultGeometry.riftReach } + if (riftNodes.size >= 2) { + val riftPath = Path() + // Negative edge along normal + riftNodes.forEachIndexed { i, node -> + val absS = kotlin.math.abs(node.s) + val normS = absS / faultGeometry.riftReach + val env = (1f - normS * normS) + val currentHalf = faultGeometry.riftHalfMax * env * riftOpen + val px = (node.basePt.x - faultGeometry.nDir.x * currentHalf) * bW + val py = (node.basePt.y - faultGeometry.nDir.y * currentHalf) * bH + if (i == 0) riftPath.moveTo(px, py) else riftPath.lineTo(px, py) + } + // Positive edge along normal in reverse + for (i in riftNodes.indices.reversed()) { + val node = riftNodes[i] + val absS = kotlin.math.abs(node.s) + val normS = absS / faultGeometry.riftReach + val env = (1f - normS * normS) + val currentHalf = faultGeometry.riftHalfMax * env * riftOpen + val px = (node.basePt.x + faultGeometry.nDir.x * currentHalf) * bW + val py = (node.basePt.y + faultGeometry.nDir.y * currentHalf) * bH + riftPath.lineTo(px, py) + } + riftPath.close() + + // Dark subterranean depth void interior + drawPath(riftPath, color = Color.Black.copy(alpha = fadeAlpha)) + // Subtle dark rock-earth edge depth + drawPath( + riftPath, + color = Color(0xFF140A05).copy(alpha = riftOpen * 0.70f * fadeAlpha), + style = Stroke(width = 2.5f, cap = StrokeCap.Square), + ) + } + } + + // Ground-roll shockwave ring expanding from rupture + val shockP = nfSmooth(0.32f, 0.60f, p) + if (shockP in 0.01f..0.98f) { + drawOGShockwaveFrame( + OGShockwave.GROUND.copy(color = "#5D4037", maxRadius = 0.50f, squash = 0.55f, alpha = 0.55f * fadeAlpha), + originX, originY, shockP, + ) + } + } + + // ── LAYER 2: The Falling Tile (Clipped to rift so it never spills out) ───────── + val density = LocalDensity.current + BoxWithConstraints(Modifier.fillMaxSize()) { + val bW = maxWidth + val bH = maxHeight + val tW = bW * tileWidth + val tH = bH * tileHeight + + val bW_px = with(density) { bW.toPx() } + val bH_px = with(density) { bH.toPx() } + + // Construct rift clipping path in screen pixels + val riftPath = remember(riftOpen, bW_px, bH_px) { + if (riftOpen <= 0.01f) null + else { + val riftNodes = faultGeometry.nodes.filter { kotlin.math.abs(it.s) <= faultGeometry.riftReach } + if (riftNodes.size < 2) null + else { + val path = Path() + riftNodes.forEachIndexed { i, node -> + val absS = kotlin.math.abs(node.s) + val normS = absS / faultGeometry.riftReach + val env = (1f - normS * normS) + val currentHalf = faultGeometry.riftHalfMax * env * riftOpen + val px = (node.basePt.x - faultGeometry.nDir.x * currentHalf) * bW_px + val py = (node.basePt.y - faultGeometry.nDir.y * currentHalf) * bH_px + if (i == 0) path.moveTo(px, py) else path.lineTo(px, py) + } + for (i in riftNodes.indices.reversed()) { + val node = riftNodes[i] + val absS = kotlin.math.abs(node.s) + val normS = absS / faultGeometry.riftReach + val env = (1f - normS * normS) + val currentHalf = faultGeometry.riftHalfMax * env * riftOpen + val px = (node.basePt.x + faultGeometry.nDir.x * currentHalf) * bW_px + val py = (node.basePt.y + faultGeometry.nDir.y * currentHalf) * bH_px + path.lineTo(px, py) + } + path.close() + path + } + } + } + + // P-wave subtle tremor before plunge + val tremorEnv = nfSmooth(0.12f, 0.28f, p) * (1f - nfSmooth(0.34f, 0.42f, p)) + val tileTremorX = sin(p * 280f) * with(density) { 1.8.dp.toPx() } * tremorEnv + val tileTremorY = cos(p * 240f) * with(density) { 1.2.dp.toPx() } * tremorEnv + + // Plunge physics: starts as rift opens at p=0.38, tumbling & falling directly into screen depth + val plungeP = ((p - 0.38f) / 0.44f).coerceIn(0f, 1f) + val dropCurve = plungeP * plungeP * (1.10f - 0.10f * plungeP) + + // Anchored directly at epicenter without 3D translation drift outside hole + val transX = tileTremorX * (1f - plungeP) + val transY = tileTremorY * (1f - plungeP) + + // Shrinks directly into the screen (Z-depth perspective into the abyss) + val scale = if (p < 0.38f) 1f else (1f - dropCurve * 0.94f).coerceAtLeast(0.06f) + + // Rolling directly into the screen with subtle physical wobble + val rotZ = if (p < 0.38f) 0f else (dropCurve * 85f) + val rotX = if (p < 0.38f) 0f else sin(plungeP * 4f) * 6f * (1f - plungeP) + val rotY = if (p < 0.38f) 0f else cos(plungeP * 4f) * 6f * (1f - plungeP) + + // Shadow overlay: gradually envelops the tile in subterranean darkness as it sinks + val darkAlpha = if (p < 0.40f) 0f else (plungeP * 1.25f).coerceIn(0f, 0.95f) + // Visible tile alpha: remains visible throughout descent, fading at the deep bottom + val tileAlpha = if (plungeP < 0.85f) 1f else (1f - ((plungeP - 0.85f) / 0.15f).coerceIn(0f, 1f)) + + if (tileAlpha > 0.005f) { + Box( + Modifier + .fillMaxSize() + .drawWithContent { + if (p >= 0.38f && riftPath != null) { + clipPath(riftPath) { + this@drawWithContent.drawContent() + } + } else { + this@drawWithContent.drawContent() + } + }, + ) { + Box( + Modifier + .offset( + x = bW * originX - tW / 2f, + y = bH * originY - tH / 2f, + ) + .width(tW) + .height(tH) + .graphicsLayer { + translationX = transX + translationY = transY + scaleX = scale + scaleY = scale + rotationX = rotX + rotationZ = rotZ + rotationY = rotY + alpha = tileAlpha + }, + contentAlignment = Alignment.Center, + ) { + if (tileContent != null) { + tileContent() + } else { + DefaultQuakeTile() + } + if (darkAlpha > 0.01f) { + Box( + Modifier.fillMaxSize() + .clip(RoundedCornerShape(3.dp)) + .background(Color.Black.copy(alpha = darkAlpha)), + ) + } + } + } + } + } + + // ── LAYER 3: Realistic Board Cracks & Torn Fractured Lips ─────────────────── + Canvas(Modifier.fillMaxSize()) { + val bW = size.width + val bH = size.height + + fun drawSharpCrackSegment(p1: Offset, p2: Offset, width: Float) { + val hlOffset = Offset(0.6f, -0.6f) + drawLine( + Color(0xFFFAF3EB).copy(alpha = 0.70f * fadeAlpha), + p1 + hlOffset, p2 + hlOffset, + strokeWidth = (width * 0.5f).coerceAtLeast(0.8f), + cap = StrokeCap.Square, + ) + drawLine( + Color(0xFF1E130B).copy(alpha = fadeAlpha), + p1, p2, + strokeWidth = width, + cap = StrokeCap.Square, + ) + } + + val totalNodes = faultGeometry.nodes.size + val nodesToDraw = (crackProp * totalNodes).toInt().coerceIn(0, totalNodes) + + // 1. Primary fault line (thin, sharp, realistic board crack) + for (i in 0 until nodesToDraw - 1) { + val n1 = faultGeometry.nodes[i] + val n2 = faultGeometry.nodes[i + 1] + + val absS1 = kotlin.math.abs(n1.s) + val absS2 = kotlin.math.abs(n2.s) + val inRift = absS1 < faultGeometry.riftReach || absS2 < faultGeometry.riftReach + + if (inRift && riftOpen > 0.05f) { + val env1 = (1f - (absS1 / faultGeometry.riftReach).coerceIn(0f, 1f).let { it * it }) + val env2 = (1f - (absS2 / faultGeometry.riftReach).coerceIn(0f, 1f).let { it * it }) + val hg1 = faultGeometry.riftHalfMax * env1 * riftOpen + val hg2 = faultGeometry.riftHalfMax * env2 * riftOpen + + val edgeA1 = Offset((n1.basePt.x - faultGeometry.nDir.x * hg1) * bW, (n1.basePt.y - faultGeometry.nDir.y * hg1) * bH) + val edgeA2 = Offset((n2.basePt.x - faultGeometry.nDir.x * hg2) * bW, (n2.basePt.y - faultGeometry.nDir.y * hg2) * bH) + val edgeB1 = Offset((n1.basePt.x + faultGeometry.nDir.x * hg1) * bW, (n1.basePt.y + faultGeometry.nDir.y * hg1) * bH) + val edgeB2 = Offset((n2.basePt.x + faultGeometry.nDir.x * hg2) * bW, (n2.basePt.y + faultGeometry.nDir.y * hg2) * bH) + + drawSharpCrackSegment(edgeA1, edgeA2, width = 1.8f) + drawSharpCrackSegment(edgeB1, edgeB2, width = 1.8f) + } else { + val p1 = Offset(n1.basePt.x * bW, n1.basePt.y * bH) + val p2 = Offset(n2.basePt.x * bW, n2.basePt.y * bH) + drawSharpCrackSegment(p1, p2, width = 2.2f) + } + } + + // Advancing fracture spark at leading tip + if (crackProp in 0.05f..0.98f && nodesToDraw in 1 until totalNodes && fadeAlpha > 0.1f) { + val tip = faultGeometry.nodes[nodesToDraw].basePt + val tipPx = Offset(tip.x * bW, tip.y * bH) + drawCircle(Color(0xFFFFD54F).copy(alpha = 0.75f * fadeAlpha), radius = 3.5f, center = tipPx) + drawCircle(Color.White.copy(alpha = fadeAlpha), radius = 1.8f, center = tipPx) + } + + // 2. Secondary hairline stress branches off primary fault + for (branch in faultGeometry.branches) { + val branchP = nfSmooth(0.24f, 0.44f, p) + if (branchP > 0f && branch.size >= 2) { + val bTotal = branch.size - 1 + val bDrawCount = (branchP * bTotal).toInt().coerceIn(0, bTotal) + for (bi in 0 until bDrawCount) { + val bp1 = Offset(branch[bi].x * bW, branch[bi].y * bH) + val bp2 = Offset(branch[bi + 1].x * bW, branch[bi + 1].y * bH) + drawSharpCrackSegment(bp1, bp2, width = 1.2f) + } + } + } + + // 3. Auxiliary smaller cracks scattered across different parts of the board + val minorProp = nfSmooth(0.18f, 0.42f, p) + if (minorProp > 0f) { + for (mCrack in faultGeometry.minorCracks) { + val totalM = mCrack.points.size + val mDrawCount = (minorProp * totalM).toInt().coerceIn(0, totalM) + for (i in 0 until mDrawCount - 1) { + val p1 = Offset(mCrack.points[i].x * bW, mCrack.points[i].y * bH) + val p2 = Offset(mCrack.points[i + 1].x * bW, mCrack.points[i + 1].y * bH) + drawSharpCrackSegment(p1, p2, width = 1.6f) + } + val mBranchProp = nfSmooth(0.28f, 0.48f, p) + if (mBranchProp > 0f) { + for (mb in mCrack.branches) { + val bDraw = (mBranchProp * mb.size).toInt().coerceIn(0, mb.size) + for (bi in 0 until bDraw - 1) { + val bp1 = Offset(mb[bi].x * bW, mb[bi].y * bH) + val bp2 = Offset(mb[bi + 1].x * bW, mb[bi + 1].y * bH) + drawSharpCrackSegment(bp1, bp2, width = 1.0f) + } + } + } + } + } + } + + OGParticleView(stoneDust, Modifier.fillMaxSize(), playing = rupturing) + } + } + + /** + * Drop-in anime-grade **Flood Event** view. + * Features: + * - Sweeping flood torrent across the board with advancing inundation water sheet. + * - Surging Gerstner wave crest with churning frothing breaker lip and spray motes. + * - Realistic Floating Tile: When hit by the wave, water buoyancy lifts the tile off the board; + * it bobs on the rolling swells, rocks naturally (pitch and roll), carries along with the current, + * and washes away with foam and splash ripples. + */ + @Composable + fun Flood( + modifier: Modifier = Modifier, + targetX: Float = 0.5f, + targetY: Float = 0.5f, + tileWidth: Float = 0.125f, + tileHeight: Float = 0.125f, + playing: Boolean = true, + durationMs: Long = 5200L, + seed: Int = 42, + tileContent: (@Composable () -> Unit)? = null, + onDone: (() -> Unit)? = null, + ) { + val progress = remember(seed) { Animatable(0f) } + var sprayPlaying by remember(seed) { mutableStateOf(false) } + + val cx = targetX + val cy = targetY + + val waterMist = remember(seed) { + OGFog.MIST.copy( + color = "#29B6F6", density = 8, opacity = 0.07f, scale = 0.60f, + driftX = 0.010f, driftY = 0.07f, turbulence = 0.10f, wanderSpeed = 0.25f, seed = seed, + ) + } + val rainParticles = remember { + OGParticles.RAIN.copy(maxParticles = 250, emissionRate = 180f, speed = 1.3f) + } + val droplets = remember(seed) { + OGParticles.DROPLETS.copy( + burst = 30, emissionRate = 42f, maxParticles = 95, + x = 0.5f, y = 0.0f, spawnRadius = 0.50f, + angleDeg = 90f, spreadDeg = 140f, speed = 0.62f, speedJitter = 0.48f, + lifetimeMs = 1200f, gravityY = 0.28f, + startColor = "#4FC3F7", endColor = "#0277BD", endAlpha = 0f, seed = seed, + ) + } + val underwaterAurora = remember(seed) { + OGAurora.DREAMY.copy( + bottomColor = "#B3E5FC", midColor = "#4FC3F7", topColor = "#E1F5FE", + opacity = 0.12f, speed = 0.85f, amplitude = 0.04f, curtainHeight = 1.0f, + curtainY = 0.0f, columns = 11, seed = seed, + ) + } + // Rising water spray from below — surging upward from wave impact + val floodSpray = remember(seed, cx) { + OGParticles.DROPLETS.copy( + burst = 0, emissionRate = 24f, maxParticles = 60, + x = cx, y = 1.05f, spawnRadius = 0.48f, + angleDeg = 270f, spreadDeg = 55f, + speed = 0.22f, speedJitter = 0.70f, gravityY = -0.04f, + lifetimeMs = 2200f, drag = 0.08f, + startSize = 0.010f, endSize = 0.003f, sizeJitter = 0.6f, + startColor = "#B3E5FC", endColor = "#4FC3F7", startAlpha = 0.65f, endAlpha = 0f, + seed = seed + 31, + ) + } + + // Ripples: capillary rings spawn as flood torrent sweeps through + val rippleState = remember(seed) { OGRippleState() } + val rippleSpec = remember(seed) { + OGRipple.POND.copy( + propagationSpeed = 0.28f, decayLambda = 2.2f, maxRadius = 0.58f, + secondaryRings = 5, ringSpacing = 0.062f, + color = "#81D4FA", crestColor = "#E1F5FE", lineWidth = 0.006f, + squash = 0.68f, crownDroplets = false, seed = seed, + ) + } + + // Wave arrival progress at the target tile + val pHit = remember(cx) { + 0.08f + ((cx + 0.15f) / 1.50f).coerceIn(0f, 1f) * (0.80f - 0.08f) + } + + // Flood sequence: spray begins, wave sweeps across + LaunchedEffect(seed, playing) { + if (!playing) return@LaunchedEffect + sprayPlaying = true + } + LaunchedEffect(seed, playing) { + if (!playing) return@LaunchedEffect + val hitMs = (pHit * durationMs).toLong().coerceAtLeast(200L) + delay(hitMs) + var nowMs = 0L; withFrameMillis { nowMs = it } + rippleState.spawn(cx, cy, nowMs) + rippleState.spawn(cx - 0.10f, cy + 0.06f, nowMs) + val rng = Random(seed + 99) + repeat(6) { + delay(320L); withFrameMillis { nowMs = it } + rippleState.spawn(rng.nextFloat() * 0.7f + 0.15f, rng.nextFloat() * 0.7f + 0.15f, nowMs) + } + } + LaunchedEffect(seed, playing) { + if (!playing) return@LaunchedEffect + progress.animateTo(1f, tween(durationMs.toInt(), easing = LinearEasing)) + onDone?.invoke() + } + + val p = progress.value + + // Tidal surge heave (camera undulates gently with the rising flood mass) + val heaveY = sin(p * 5f * PI.toFloat()) * 8f * nfSmooth(0.08f, 0.40f, p) * (1f - nfSmooth(0.82f, 1f, p)) + + Box( + modifier.fillMaxSize().graphicsLayer { + alpha = nfSmooth(0f, 0.05f, p) * (1f - nfSmooth(0.88f, 1f, p)) + translationY = heaveY + }.clipToBounds(), + ) { + // Underwater aurora shimmer + OGAuroraView( + underwaterAurora, + Modifier.fillMaxSize().graphicsLayer { + alpha = nfSmooth(0.12f, 0.25f, p) * (1f - nfSmooth(0.72f, 0.88f, p)) + }, + ) + + // Rising water spray from below + OGParticleView(floodSpray, Modifier.fillMaxSize(), playing = sprayPlaying) + + // ── LAYER 1: Inundation Water Sheet (Submerged board behind wave) ───────────── + Canvas(Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + + // Wave progress: sweeps across from left to right (from -0.15 to 1.35) + val floodSweep = nfSmooth(0.06f, 0.82f, p) + val frontX = (-0.15f + floodSweep * 1.50f) * w + + if (frontX > -50f && frontX < w + 200f) { + val segments = 32 + + // Profile of the surging wave crest across the screen height + fun waveProfile(y: Float): Float { + val normY = y / h + return frontX + + sin(normY * 4f * PI.toFloat() + p * 10f) * (w * 0.035f) + + cos(normY * 7f * PI.toFloat() - p * 7f) * (w * 0.020f) + } + + // Submerged Inundation Water Sheet + val waterSheetPath = Path().apply { + moveTo(0f, 0f) + lineTo(waveProfile(0f), 0f) + for (i in 1..segments) { + val y = (i.toFloat() / segments) * h + lineTo(waveProfile(y), y) + } + lineTo(0f, h) + close() + } + val waterDepthBrush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to Color(0xE60D233A), + 0.65f to Color(0xCC01579B), + 0.92f to Color(0xA600B0FF), + 1f to Color(0x66E0F7FA), + ), + startX = 0f, + endX = frontX, + ) + drawPath(waterSheetPath, brush = waterDepthBrush) + + // Rising Cavitation Bubbles churning in the flooded volume + if (frontX > 10f) { + val bubbleRng = Random(seed + 19) + val bubbleCount = 18 + for (b in 0 until bubbleCount) { + val bxFrac = bubbleRng.nextFloat() + val bx = bxFrac * frontX + val by = ((bubbleRng.nextFloat() + p * 0.55f + b * 0.11f) % 1f) * h + val br = 3.5f + bubbleRng.nextFloat() * 8.5f + val bAlpha = (nfSmooth(0.05f, 0.25f, bx / frontX) * (1f - nfSmooth(0.90f, 1f, bx / frontX))).coerceIn(0f, 1f) + if (bx > 5f && bAlpha > 0.05f) { + drawOGBubbleFrame(bx, by, br, tint = Color(0xFF81D4FA), alpha = bAlpha * 0.80f) + } + } + } + } + } + + // ── LAYER 2: The Floating Tile (Bobbing, Rocking & Washing with the Waves) ─── + val density = LocalDensity.current + BoxWithConstraints(Modifier.fillMaxSize()) { + val bW = maxWidth + val bH = maxHeight + val tW = bW * tileWidth + val tH = bH * tileHeight + + val isFloating = p >= pHit + val floatP = if (isFloating) ((p - pHit) / (0.92f - pHit)).coerceIn(0f, 1f) else 0f + + // Pre-hit subtle water lap tremor as wave approaches + val preHitP = ((p - (pHit - 0.05f)) / 0.05f).coerceIn(0f, 1f) + val preTremorY = if (!isFloating && p >= pHit - 0.05f) -preHitP * with(density) { 2.dp.toPx() } else 0f + + // 1. Buoyancy lift: tile pops up off the board as water rushes beneath + val liftProgress = (floatP / 0.12f).coerceIn(0f, 1f) + val liftY = sin(liftProgress * PI.toFloat() / 2f) * with(density) { 16.dp.toPx() } + + // 2. Wave bobbing oscillation: rhythmic vertical bob in the churning water + val bobY = sin(floatP * 14f * PI.toFloat()) * with(density) { 5.dp.toPx() } + val totalTransY = if (!isFloating) preTremorY else (-liftY + bobY) + + // 3. Current drift: rushes to the right with the wave + val driftCurve = floatP * floatP * (1.10f - 0.10f * floatP) + val transX = if (!isFloating) 0f else driftCurve * with(density) { (bW * 0.42f).toPx() } + + // 4. Floating pitch & roll: rocks naturally like a wooden block on rolling swells + val rollZ = if (!isFloating) 0f else sin(floatP * 11f * PI.toFloat()) * 12f + val pitchX = if (!isFloating) 0f else (12f + cos(floatP * 9f * PI.toFloat()) * 8f) + val yawY = if (!isFloating) 0f else sin(floatP * 7f * PI.toFloat()) * 8f + + // 5. Fade out as it washes off the board + val washFade = if (floatP < 0.78f) 1f else (1f - ((floatP - 0.78f) / 0.22f)).coerceIn(0f, 1f) + val scale = if (!isFloating) 1f else (1f - (floatP - 0.78f).coerceAtLeast(0f) * 0.25f) + + if (washFade > 0.005f) { + Box( + Modifier + .offset( + x = bW * cx - tW / 2f, + y = bH * cy - tH / 2f, + ) + .width(tW) + .height(tH) + .graphicsLayer { + translationX = transX + translationY = totalTransY + scaleX = scale + scaleY = scale + rotationZ = rollZ + rotationX = pitchX + rotationY = yawY + alpha = washFade + }, + contentAlignment = Alignment.Center, + ) { + if (tileContent != null) { + tileContent() + } else { + DefaultFloodTile() + } + // Wet water sheen overlay when afloat + if (isFloating) { + Box( + Modifier.fillMaxSize() + .clip(RoundedCornerShape(3.dp)) + .background(Color(0x3080D8FF)), + ) + } + } + } + } + + // ── LAYER 3: Frothing Wave Breaker, Spray, Caustics & Splashes ────────────── + Canvas(Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + + val floodSweep = nfSmooth(0.06f, 0.82f, p) + val frontX = (-0.15f + floodSweep * 1.50f) * w + + if (frontX > -50f && frontX < w + 200f) { + val segments = 32 + fun waveProfile(y: Float): Float { + val normY = y / h + return frontX + + sin(normY * 4f * PI.toFloat() + p * 10f) * (w * 0.035f) + + cos(normY * 7f * PI.toFloat() - p * 7f) * (w * 0.020f) + } + + // Shimmering Caustic Light Ribbons on the flooded surface + val causticsCount = 6 + for (c in 0 until causticsCount) { + val caustY = (c.toFloat() / causticsCount) * h + sin(p * 7f + c) * 15f + val caustPath = Path() + val caustEnd = (waveProfile(caustY) - 20f).coerceAtLeast(0f) + if (caustEnd > 10f) { + caustPath.moveTo(0f, caustY) + val cSteps = 12 + for (ci in 1..cSteps) { + val cxVal = (ci.toFloat() / cSteps) * caustEnd + val cyVal = caustY + sin(cxVal * 0.04f + p * 8f + c) * 8f + caustPath.lineTo(cxVal, cyVal) + } + drawPath( + caustPath, + color = Color(0x28B3E5FC), + style = Stroke(width = 4f, cap = StrokeCap.Round), + ) + } + } + + // Frothing Wave Breaker Lip (Smooth rolling ocean wave crest with aerated foam) + val lipPath = Path().apply { + moveTo(waveProfile(0f), 0f) + for (i in 1..segments) { + val y = (i.toFloat() / segments) * h + lineTo(waveProfile(y), y) + } + } + drawPath(lipPath, color = Color(0x9980D8FF), style = Stroke(width = 24f, cap = StrokeCap.Round)) + drawPath(lipPath, color = Color.White.copy(alpha = 0.90f), style = Stroke(width = 8f, cap = StrokeCap.Round)) + + // Organic Whitewater Foam Discs along the advancing wave lip + val foamRng = Random(seed + 41) + val crestFoamCount = 22 + for (f in 0 until crestFoamCount) { + val fy = (f.toFloat() / crestFoamCount) * h + (foamRng.nextFloat() - 0.5f) * 20f + val fx = waveProfile(fy) + (foamRng.nextFloat() - 0.5f) * 18f + val fr = 6f + foamRng.nextFloat() * 12f + drawCircle(Color.White.copy(alpha = 0.80f), radius = fr, center = Offset(fx, fy)) + } + + // Wind-Whipped Spray Motes flying off the wave lip + val rng = Random(seed + (p * 80).toInt()) + for (k in 0 until 18) { + val py = rng.nextFloat() * h + val px = waveProfile(py) + rng.nextFloat() * 40f + drawCircle( + color = Color.White.copy(alpha = 0.80f), + radius = 2f + rng.nextFloat() * 2.5f, + center = Offset(px, py), + ) + } + } + + // Expanding water splash shockwave ring on surge impact + val surgeP = nfSmooth(pHit, pHit + 0.28f, p) + if (p >= pHit && surgeP < 0.98f) { + drawOGShockwaveFrame( + OGShockwave.SPLASH.copy(color = "#4FC3F7", maxRadius = 0.65f, ringWidth = 0.024f, squash = 0.58f, alpha = 0.80f), + cx, cy, surgeP, + ) + } + } + + // Ripple rings from impact and droplet splashes + OGRippleView(rippleState, rippleSpec, Modifier.fillMaxSize(), playing = playing) + + // Mist and driving torrential rain + OGFogView(waterMist, Modifier.fillMaxSize()) + OGParticleView(droplets, Modifier.fillMaxSize(), playing = playing) + OGParticleView(rainParticles, Modifier.fillMaxSize(), playing = playing) + } + } +} + +// ── Shared Animation Math Helpers ───────────────────────────────────────────────────────────── + +private data class ThunderBurnData( + val strikeOffset: Offset, + val scorchPolygon: List, + val sootPolygon: List, + val emberCracks: List>, + val scorchSpots: List>, +) + +private data class FaultNode( + val s: Float, + val basePt: Offset, +) + +private data class MinorCrack( + val points: List, + val branches: List>, +) + +private data class QuakeFaultData( + val uDir: Offset, + val nDir: Offset, + val nodes: List, + val branches: List>, + val minorCracks: List, + val riftReach: Float, + val riftHalfMax: Float, +) + +@Composable +private fun DefaultThunderTile() { + Box( + Modifier.fillMaxSize() + .clip(RoundedCornerShape(3.dp)) + .background(Color(0xFFFAF7F0)) + .border(1.dp, Color(0xFFFFB300), RoundedCornerShape(3.dp)), + contentAlignment = Alignment.Center, + ) { + androidx.compose.material3.Text("⚡", fontSize = 18.sp, color = Color(0xFFE65100)) + } +} + +@Composable +private fun DefaultQuakeTile() { + Box( + Modifier.fillMaxSize() + .clip(RoundedCornerShape(3.dp)) + .background(Color(0xFFFAF7F0)) + .border(1.dp, Color(0xFF8D6E63), RoundedCornerShape(3.dp)), + contentAlignment = Alignment.Center, + ) { + androidx.compose.material3.Text("⛰", fontSize = 18.sp, color = Color(0xFF3E2723)) + } +} + +@Composable +private fun DefaultFloodTile() { + Box( + Modifier.fillMaxSize() + .clip(RoundedCornerShape(3.dp)) + .background(Color(0xFFFAF7F0)) + .border(1.dp, Color(0xFF0288D1), RoundedCornerShape(3.dp)), + contentAlignment = Alignment.Center, + ) { + androidx.compose.material3.Text("🌊", fontSize = 18.sp, color = Color(0xFF01579B)) + } +} + +private fun nfSmooth(edge0: Float, edge1: Float, x: Float): Float { + if (edge1 <= edge0) return if (x >= edge1) 1f else 0f + val u = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f) + return u * u * (3f - 2f * u) +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFirefly.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFirefly.kt new file mode 100644 index 0000000..5b9a1b2 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFirefly.kt @@ -0,0 +1,75 @@ +package com.solidkey.painpoints.fx + +import com.solidkey.painpoints.ai.OGAiVector + +/** + * Codec + presets for [OGFireflySpec]. + * + * Presets: + * - [MEADOW] — large open swarm, moderate coupling → waves of synchronised pulses + * - [FOREST] — smaller, dimmer, well-coupled → tight collective flashing + * - [GHIBLI] — wide spread, low coupling → independent glimmers with occasional cluster locks + */ +object OGFirefly { + val json get() = OGAiVector.json + + fun decodeSpec(text: String): OGFireflySpec = + json.decodeFromString(OGFireflySpec.serializer(), OGAiVector.extractJson(text)) + + fun decodeSpecOrNull(text: String): OGFireflySpec? = runCatching { decodeSpec(text) }.getOrNull() + + fun encode(spec: OGFireflySpec): String = json.encodeToString(OGFireflySpec.serializer(), spec) + + fun preset(name: String): OGFireflySpec? = presets[name.lowercase()] + + fun fireflyPrompt(instruction: String): String = """ + You output ONLY a JSON object describing a firefly swarm using the Kuramoto coupled-oscillator + model for the KMPMedia library. + + Each firefly has a natural blink frequency (omegaMean ± omegaSpread rad/s) and a coupling + constant K. When K > K_c ≈ 2/π·omegaSpread, clusters synchronise and pulse together. + radius and glowRadius are fractions of the shorter view dimension; driftSpeed is fraction/sec. + + Fields (all optional): name, count, coupling, omegaMean, omegaSpread, driftSpeed, turbulence, + radius, color, glowColor, glowRadius, blinkPower, opacity, seed. + + Examples: + {"name":"meadow","count":52,"coupling":2.8,"omegaMean":1.6,"color":"#CCFF90"} + {"name":"forest","count":28,"coupling":4.5,"omegaMean":2.2,"color":"#B9F6CA","glowColor":"#E8F5E9","opacity":0.85} + {"name":"ghibli","count":80,"coupling":1.2,"omegaSpread":1.4,"driftSpeed":0.02,"color":"#F9FBE7"} + + $instruction + Output ONLY the JSON object. + """.trimIndent() + + // ── Presets ────────────────────────────────────────────────────────────────────────────────── + + /** Warm summer meadow — moderate coupling, visible synchronisation waves. */ + val MEADOW = OGFireflySpec( + name = "meadow", + count = 52, coupling = 2.8f, omegaMean = 1.6f, omegaSpread = 0.7f, + driftSpeed = 0.030f, turbulence = 0.55f, + radius = 0.008f, color = "#CCFF90", glowColor = "#F1F8E9", glowRadius = 0.038f, + blinkPower = 4f, opacity = 1f, + ) + + /** Dense forest — tight coupling, collective flash. */ + val FOREST = OGFireflySpec( + name = "forest", + count = 30, coupling = 4.5f, omegaMean = 2.2f, omegaSpread = 0.5f, + driftSpeed = 0.018f, turbulence = 0.40f, + radius = 0.007f, color = "#B9F6CA", glowColor = "#E8F5E9", glowRadius = 0.030f, + blinkPower = 5f, opacity = 0.85f, + ) + + /** Ghibli dreamscape — wide spread, loose coupling → independent glimmers. */ + val GHIBLI = OGFireflySpec( + name = "ghibli", + count = 80, coupling = 1.2f, omegaMean = 1.1f, omegaSpread = 1.4f, + driftSpeed = 0.018f, turbulence = 0.70f, + radius = 0.006f, color = "#F9FBE7", glowColor = "#FFFFFF", glowRadius = 0.028f, + blinkPower = 3f, opacity = 0.78f, + ) + + val presets: Map = listOf(MEADOW, FOREST, GHIBLI).associateBy { it.name!! } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFireflySpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFireflySpec.kt new file mode 100644 index 0000000..13794aa --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFireflySpec.kt @@ -0,0 +1,64 @@ +package com.solidkey.painpoints.fx + +import kotlinx.serialization.Serializable + +/** + * Describes a swarm of synchronising fireflies governed by the **Kuramoto coupled-oscillator model** + * (Kuramoto 1975 / Strogatz 2000). Each firefly i has a natural frequency ω_i drawn from a spread + * around [omegaMean] and a phase θ_i. Per frame: + * + * dθ_i/dt = ω_i + K · r · sin(Ψ − θ_i) + * + * where r·e^{iΨ} is the complex mean-field order parameter computed in O(N). When coupling + * [coupling] K > K_c ≈ 2/π · [omegaSpread], local clusters lock phases and luminescence waves + * propagate through the swarm — the emergent synchronization you see in Ghibli summer meadows. + * + * Luminescence: I_i(t) = max(0, sin θ_i)^[blinkPower]. Positions drift with toroidal Brownian + * random walk (no allocation, pure fBm wander per firefly). + * + * Pure `commonMain` — zero shaders, O(N) per frame, ~0.04ms for N=50 on mobile. + * + * Presets and codec on [OGFirefly]. Rendered by [OGFireflyView]. + */ +@Serializable +data class OGFireflySpec( + val name: String? = null, + /** Number of fireflies. */ + val count: Int = 48, + /** Kuramoto coupling constant K. Above K_c ≈ 2/π·omegaSpread, clusters synchronise. */ + val coupling: Float = 2.8f, + /** Mean natural oscillation frequency (radians/sec). Higher = faster blink rate. */ + val omegaMean: Float = 1.6f, + /** Spread of natural frequencies. Lower = all fireflies more similar → easier to sync. */ + val omegaSpread: Float = 0.7f, + /** Spatial drift speed as fraction of shorter view side per second. */ + val driftSpeed: Float = 0.030f, + /** Magnitude of Brownian turbulence in the drift path. */ + val turbulence: Float = 0.55f, + /** Firefly core radius as fraction of the shorter view side. */ + val radius: Float = 0.008f, + /** Core glow colour (HEX). */ + val color: String = "#CCFF90", + /** Outer soft halo colour (HEX). */ + val glowColor: String = "#F1F8E9", + /** Glow halo radius as fraction of shorter side. */ + val glowRadius: Float = 0.038f, + /** Exponent for the luminescence curve max(0,sin θ)^blinkPower. Higher = sharper flash. */ + val blinkPower: Float = 4f, + /** Overall brightness scale (0..1). */ + val opacity: Float = 1f, + val seed: Int = 0, +) { + fun sanitized() = copy( + count = count.coerceIn(1, 500), + coupling = coupling.coerceIn(0f, 20f), + omegaMean = omegaMean.coerceIn(0.1f, 20f), + omegaSpread = omegaSpread.coerceIn(0f, 10f), + driftSpeed = driftSpeed.coerceIn(0f, 1f), + turbulence = turbulence.coerceIn(0f, 5f), + radius = radius.coerceIn(0.001f, 0.1f), + glowRadius = glowRadius.coerceIn(0.002f, 0.3f), + blinkPower = blinkPower.coerceIn(1f, 16f), + opacity = opacity.coerceIn(0f, 1f), + ) +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFireflyView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFireflyView.kt new file mode 100644 index 0000000..4169356 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFireflyView.kt @@ -0,0 +1,159 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import com.solidkey.painpoints.ai.toSpecColor +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.random.Random + +/** + * Holds the live simulation state for a firefly swarm: per-fly phases, natural frequencies, + * positions, and wander offsets. Build once via [rememberOGFireflyState] and pass to [OGFireflyView]. + * The state is mutable and updated in-place each frame — no per-frame allocation. + */ +class OGFireflyState(spec: OGFireflySpec) { + private val N = spec.count.coerceIn(1, 500) + + internal val phases = FloatArray(N) // θ_i (radians) + internal val omegas = FloatArray(N) // ω_i (natural frequency, rad/s) + internal val posX = FloatArray(N) // normalized [0,1] + internal val posY = FloatArray(N) + internal val wanderX = FloatArray(N) + internal val wanderY = FloatArray(N) + + init { + val rng = Random(spec.seed) + val twoPI = 2f * PI.toFloat() + for (i in 0 until N) { + phases[i] = rng.nextFloat() * twoPI + omegas[i] = spec.omegaMean + (rng.nextFloat() - 0.5f) * 2f * spec.omegaSpread + posX[i] = rng.nextFloat() + posY[i] = rng.nextFloat() + wanderX[i] = rng.nextFloat() * twoPI + wanderY[i] = rng.nextFloat() * twoPI + } + } + + /** Advance the Kuramoto simulation and positions by [dt] seconds. O(N). */ + internal fun update(spec: OGFireflySpec, dt: Float) { + val dtC = dt.coerceIn(0f, 0.064f) + val n = phases.size + + // Kuramoto mean-field order parameter: r·e^{iΨ} = (1/N) Σ e^{iθ_j} + var sumCos = 0f; var sumSin = 0f + for (i in 0 until n) { sumCos += cos(phases[i]); sumSin += sin(phases[i]) } + val r = sqrt(sumCos * sumCos + sumSin * sumSin) / n + val psi = atan2(sumSin, sumCos) + + // Phase update + Brownian drift + val twoPI = 2f * PI.toFloat() + for (i in 0 until n) { + phases[i] += (omegas[i] + spec.coupling * r * sin(psi - phases[i])) * dtC + + wanderX[i] += spec.turbulence * dtC * (0.8f + omegas[i] * 0.12f) + wanderY[i] += spec.turbulence * dtC * (0.6f + omegas[i] * 0.09f) + posX[i] = frac(posX[i] + spec.driftSpeed * dtC * cos(wanderX[i])) + posY[i] = frac(posY[i] + spec.driftSpeed * dtC * sin(wanderY[i])) + } + } + + private fun frac(x: Float): Float = x - floor(x) +} + +/** Creates and [remember]s an [OGFireflyState] keyed on [spec]. */ +@Composable +fun rememberOGFireflyState(spec: OGFireflySpec): OGFireflyState = + remember(spec) { OGFireflyState(spec) } + +/** + * Renders a live firefly swarm using the [OGFireflySpec]'s Kuramoto coupled oscillator model. + * The state is maintained inside the composable; set [playing] to `false` to freeze. + * + * Tip: overlay this on a dark scene. The glow halos are drawn with normal blending; for additive + * sparkle use a parent `graphicsLayer { blendMode = BlendMode.Screen }`. + */ +@Composable +fun OGFireflyView( + spec: OGFireflySpec, + modifier: Modifier = Modifier, + playing: Boolean = true, +) { + val s = remember(spec) { spec.sanitized() } + val state = remember(s) { OGFireflyState(s) } + + val coreColor = remember(s) { s.color.toSpecColor() ?: Color(0xFFCCFF90) } + val haloColor = remember(s) { s.glowColor.toSpecColor() ?: Color(0xFFF1F8E9) } + + var tick by remember { mutableIntStateOf(0) } + LaunchedEffect(s, playing) { + if (!playing) return@LaunchedEffect + var prev = withFrameMillis { it } + while (true) { + withFrameMillis { now -> + val dt = (now - prev) / 1000f + prev = now + state.update(s, dt) + tick++ + } + } + } + + Canvas(modifier.clipToBounds()) { + @Suppress("UNUSED_EXPRESSION") tick + drawOGFireflyFrame(s, state, coreColor, haloColor) + } +} + +/** + * Draw one frame of the firefly swarm from pre-computed [state]. + * Shared by [OGFireflyView] and any off-screen baker. + */ +fun DrawScope.drawOGFireflyFrame( + spec: OGFireflySpec, + state: OGFireflyState, + coreColor: Color, + haloColor: Color, +) { + val minDim = min(size.width, size.height) + val coreR = spec.radius * minDim + val haloR = spec.glowRadius * minDim + val N = state.phases.size + + for (i in 0 until N) { + // Luminescence: max(0, sin(θ))^blinkPower + val sinVal = sin(state.phases[i]) + if (sinVal <= 0f) continue + val brightness = sinVal.pow(spec.blinkPower) * spec.opacity + if (brightness < 0.008f) continue + + val px = state.posX[i] * size.width + val py = state.posY[i] * size.height + val pos = Offset(px, py) + + // Outer halo — soft, large + drawCircle(haloColor.copy(alpha = brightness * 0.28f), haloR, pos) + // Mid glow + drawCircle(coreColor.copy(alpha = brightness * 0.55f), haloR * 0.5f, pos) + // Core + drawCircle(coreColor.copy(alpha = brightness * 0.92f), coreR, pos) + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFlame.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFlame.kt new file mode 100644 index 0000000..6f11324 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFlame.kt @@ -0,0 +1,76 @@ +package com.solidkey.painpoints.fx + +import com.solidkey.painpoints.ai.OGAiVector + +/** + * The **codec + presets** for [OGFlameSpec] — decode a JSON pack, encode one back, build a model prompt, + * and reach for a ready-made fire ([CANDLE], [CAMPFIRE], [TORCH], [INFERNO]). Mirrors the rest of the FX / + * data layers: lenient JSON, tolerant extraction, no network, no AI SDK. + */ +object OGFlame { + val json get() = OGAiVector.json + + fun decodeSpec(text: String): OGFlameSpec = + json.decodeFromString(OGFlameSpec.serializer(), OGAiVector.extractJson(text)) + + fun decodeSpecOrNull(text: String): OGFlameSpec? = runCatching { decodeSpec(text) }.getOrNull() + + fun encode(spec: OGFlameSpec): String = json.encodeToString(OGFlameSpec.serializer(), spec) + + /** Look up a built-in preset by name (case-insensitive), or `null`. */ + fun preset(name: String): OGFlameSpec? = presets[name.lowercase()] + + fun flamePrompt(instruction: String): String = """ + You output ONLY a JSON object describing a body of fire for the KMPMedia library. + + It renders as upward-licking tongues rooted along a baseline, flickering and waving, with a hot + core colour at the base fading to a cooler tip colour. Coordinates are 0..1 of the view: height, + width, sway and wind are fractions of the view; baseY is where the fire sits (1 = the bottom). + wind bends the whole flame sideways (+ right, - left) and is meant to track a scene's breeze. + + Fields (all optional): name, coreColor, tipColor, tongues, height, heightJitter, width, sway, + wind, swaySpeed, flicker, flickerSpeed, octaves, opacity, baseY, seed. + + Schema: {"name":"", ...fields...} + Example: {"name":"campfire","coreColor":"#FFD24D","tipColor":"#FF3B10","tongues":10,"height":0.6,"flicker":0.35} + + Output the JSON only, with no prose and no markdown fences. + + Task: $instruction + """.trimIndent() + + /** A single calm candle flame — one slim warm tongue, a gentle flicker, easy to bend in a draught. */ + val CANDLE = OGFlameSpec( + name = "candle", coreColor = "#FFF3C4", tipColor = "#FF8A2A", + tongues = 1, height = 0.5f, heightJitter = 0.12f, width = 0.09f, + sway = 0.03f, swaySpeed = 0.9f, flicker = 0.22f, flickerSpeed = 2.0f, octaves = 2, + opacity = 0.95f, seed = 7, + ) + + /** A warm hearth fire — a spread of overlapping orange tongues, lively but contained. */ + val CAMPFIRE = OGFlameSpec( + name = "campfire", coreColor = "#FFD24D", tipColor = "#FF3B10", + tongues = 10, height = 0.6f, heightJitter = 0.5f, width = 0.17f, + sway = 0.06f, swaySpeed = 1.1f, flicker = 0.34f, flickerSpeed = 2.4f, octaves = 3, + opacity = 0.92f, seed = 19, + ) + + /** A tight hand-torch flame — fewer, taller tongues with a strong steady lean, as if carried. */ + val TORCH = OGFlameSpec( + name = "torch", coreColor = "#FFE08A", tipColor = "#FF4312", + tongues = 4, height = 0.78f, heightJitter = 0.3f, width = 0.13f, + sway = 0.05f, wind = 0.08f, swaySpeed = 1.4f, flicker = 0.28f, flickerSpeed = 2.8f, octaves = 3, + opacity = 0.94f, seed = 29, + ) + + /** A roaring blaze — many tall, ragged, fast-flickering tongues that fill the frame. */ + val INFERNO = OGFlameSpec( + name = "inferno", coreColor = "#FFE65C", tipColor = "#E01A00", + tongues = 20, height = 0.92f, heightJitter = 0.6f, width = 0.2f, + sway = 0.09f, swaySpeed = 1.6f, flicker = 0.45f, flickerSpeed = 3.2f, octaves = 4, + opacity = 0.95f, seed = 41, + ) + + /** All built-in presets, keyed by lowercase name. */ + val presets: Map = listOf(CANDLE, CAMPFIRE, TORCH, INFERNO).associateBy { it.name!! } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFlameSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFlameSpec.kt new file mode 100644 index 0000000..8272b6c --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFlameSpec.kt @@ -0,0 +1,144 @@ +package com.solidkey.painpoints.fx + +/** + * **Fire** — a body of living, upward-licking flame, described as plain data. Part of the + * `com.solidkey.painpoints.fx` family and the fire sibling of [OGFogSpec] (smoke) and the particle + * `EMBERS` preset (sparks). Decode + presets on [OGFlame]; draw with [OGFlameView]. + * + * **How it stays 60fps (the perf gate).** No fluid sim, no per-pixel shader (the library floors runtime + * shaders out). A fire is a handful of **tongues** — tapered flame shapes rooted along a baseline — whose + * centrelines **wave** with fractal [OGFxNoise] noise and whose heights **flicker** over time. Each + * tongue is one GPU-filled path with a vertical hot→cool gradient; the noise is sampled a few times per + * tongue, never per pixel. It is pure + deterministic from [seed], so it is frame-identical on Android & + * iOS and bakes into an export byte-for-byte, exactly like the rest of the FX layer. + * + * **Coordinates.** Everything is normalized `0..1` of the view: [baseY] is where the flames are rooted + * (`1` = the bottom edge), [height]/[width]/[sway] are fractions of the view. `y` grows downward (screen + * convention), so a tongue rises from `baseY` up toward smaller `y`. + */ +@kotlinx.serialization.Serializable +data class OGFlameSpec( + val name: String? = null, + /** The hot colour at the **base** of each tongue (where fire is brightest) — any SVG colour string. */ + val coreColor: String = "#FFD24D", + /** The cooler colour at the **tip** of each tongue (where fire fades to smoke) — any SVG colour string. */ + val tipColor: String = "#FF4A1C", + /** Number of flame tongues across the base. More = a wider, fuller fire. Clamped `1..60`. */ + val tongues: Int = 9, + /** Average tongue height as a fraction of the view (`0..1`). */ + val height: Float = 0.62f, + /** Per-tongue random variation in height (`0..1`): `0` = all equal, higher = raggeder crown. */ + val heightJitter: Float = 0.45f, + /** Base width of a single tongue as a fraction of the view. Tongues overlap into one mass. */ + val width: Float = 0.16f, + /** How far a tongue's tip swings sideways (fraction of the view) as it licks. */ + val sway: Float = 0.06f, + /** + * A **prevailing wind** that bends the whole flame sideways (fraction of the view at the tip; `+` = + * right, `-` = left). The lean grows with height so the base stays put and the tip streams downwind — + * drive it live from the same breeze that pushes your snow/leaves so the fire reacts to the weather. + */ + val wind: Float = 0f, + /** How fast that sideways licking evolves (cycles/second-ish). */ + val swaySpeed: Float = 1.1f, + /** Height flicker amount (`0..1`): the fast up-down pulsing of the flame crown. */ + val flicker: Float = 0.3f, + /** How fast the height flicker pulses (cycles/second-ish). */ + val flickerSpeed: Float = 2.4f, + /** Fractal-noise octaves for the waving edges. Clamped `1..8`. */ + val octaves: Int = 3, + /** Overall opacity ceiling `0..1` (per-tongue peak alpha rides under this). */ + val opacity: Float = 0.92f, + /** Where the fire is rooted, `0..1` of the view height (`1` = the bottom edge). */ + val baseY: Float = 1f, + /** Seed for the deterministic tongue layout, wave and flicker. */ + val seed: Int = 0, +) { + /** Clamp every field into its sane range (lenient model/designer input never breaks a frame). */ + fun sanitized(): OGFlameSpec = copy( + tongues = tongues.coerceIn(1, 60), + height = height.coerceIn(0.02f, 1.5f), + heightJitter = heightJitter.coerceIn(0f, 1f), + width = width.coerceIn(0.01f, 1f), + sway = sway.coerceIn(0f, 0.5f), + wind = wind.coerceIn(-1f, 1f), + swaySpeed = swaySpeed.coerceAtLeast(0f), + flicker = flicker.coerceIn(0f, 1f), + flickerSpeed = flickerSpeed.coerceAtLeast(0f), + octaves = octaves.coerceIn(1, 8), + opacity = opacity.coerceIn(0f, 1f), + baseY = baseY.coerceIn(0f, 1f), + ) +} + +/** One point on a flame tongue's centreline, normalized `0..1` of the view (`y` grows downward). */ +data class OGFlamePoint(val x: Float, val y: Float) + +/** + * One resolved tongue at a given time: its [spine] centreline from base (`spine.first()`, at `baseY`) up + * to the flickering tip (`spine.last()`), the [halfWidth] of its base (fraction of the view, taper to 0 + * at the tip), its peak [alpha], and its [flickerPulse] — the raw height-flicker multiplier (around `1`) + * so the view can pulse the bright inner core in step with the crown. The view builds a tapered path + * around the spine and fills it with the hot→cool gradient. + */ +data class OGFlameTongue( + val spine: List, + val halfWidth: Float, + val alpha: Float, + val flickerPulse: Float = 0f, +) + +/** How many points sample each tongue's centreline (base → tip). More = smoother waves, marginally costlier. */ +private const val SPINE_SAMPLES = 14 + +/** + * Resolve [OGFlameSpec.tongues] flame tongues at [timeSec] — pure + deterministic from [OGFlameSpec.seed], + * so the same `(spec, time)` always yields the same fire (and both platforms match). Each tongue is rooted + * at a spread-out base x, given a jittered height that **flickers** via fractal noise, and a centreline + * that **waves** more toward the tip (rooted at the base) via fractal noise. The view scales these into + * pixels, tapers a path around each spine and fills it with the core→tip gradient. + */ +fun flameTongues(spec: OGFlameSpec, timeSec: Float): List { + val n = spec.tongues.coerceIn(1, 60) + val oct = spec.octaves.coerceIn(1, 8) + return List(n) { k -> + // Spread roots across the base, with a touch of hashed jitter so they don't look combed. + val rootX = (k + 0.5f) / n + OGFxMath.hashSigned(k * 5 + 1, spec.seed) * (0.5f / n) + val hJit = 1f + spec.heightJitter * OGFxMath.hashSigned(k * 3 + 7, spec.seed) + // Fast height pulsing; fbm is ~[-1,1] so flicker scales the crown up and down. + val flick = 1f + spec.flicker * OGFxNoise.fbm(timeSec * spec.flickerSpeed + k * 2.3f, 0f, oct, spec.seed + 61) + + // Organic fire dynamics: center tongues are tallest, edge tongues curve up in a natural cluster + val u = if (n > 1) (k.toFloat() / (n - 1) - 0.5f) * 2f else 0f + val distFromCenter = kotlin.math.abs(u) + val heightProfile = 1f - distFromCenter * 0.22f + val h = (spec.height * heightProfile * hJit * flick).coerceAtLeast(0.01f) + + // Slow per-tongue girth breathing so the body swells and relaxes, not just the crown. + val widthBreathe = 1f + 0.18f * OGFxNoise.fbm( + timeSec * spec.swaySpeed * 0.5f + k * 5.9f, 0f, 2, spec.seed + 113, + ) + val halfWidth = spec.width * 0.5f * (0.7f + 0.6f * OGFxMath.hash01(k * 9 + 2, spec.seed)) * widthBreathe + val alpha = (spec.opacity * (0.8f + 0.2f * OGFxMath.hash01(k * 11 + 4, spec.seed))).coerceIn(0f, 1f) + + // Parabolic bowl curve so tongues do not sit on a flat line: center is deepest, edges lift up + val bowlCurve = distFromCenter * distFromCenter * 0.08f * spec.height + val rootYJitter = spec.height * 0.03f * OGFxMath.hashSigned(k * 7 + 3, spec.seed) + val tongueBaseY = (spec.baseY - bowlCurve + rootYJitter).coerceIn(0.05f, 1.0f) + + val spine = List(SPINE_SAMPLES + 1) { j -> + val s = j.toFloat() / SPINE_SAMPLES // 0 at base → 1 at tip + val y = tongueBaseY - h * s // rise upward (smaller y) + // Sway grows toward the tip (rooted at the base) and wanders along the length over time. + val wave = spec.sway * s * OGFxNoise.fbm( + s * 2.5f + timeSec * spec.swaySpeed + k * 1.7f, + timeSec * spec.swaySpeed * 0.6f, + oct, spec.seed, + ) + // Prevailing wind bends the tongue downwind, quadratic in height so the base stays rooted. + val lean = spec.wind * s * s + OGFlamePoint(rootX + wave + lean, y) + } + OGFlameTongue(spine, halfWidth, alpha, flickerPulse = flick) + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFlameView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFlameView.kt new file mode 100644 index 0000000..0c7551c --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFlameView.kt @@ -0,0 +1,232 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import com.solidkey.painpoints.ai.toSpecColor +import kotlin.math.PI +import kotlin.math.exp +import kotlin.math.min +import kotlin.math.sin + +/** + * Draw and run an [spec]'s **fire** — upward-licking flame tongues filling this composable's bounds from + * [OGFlameSpec.baseY] up. Set [playing] to `false` to freeze it; pass a live [windOverride] to bend the + * whole fire with a scene's breeze (overrides [OGFlameSpec.wind] when non-null). + * + * 60fps + cross-platform: each tongue is one GPU-filled tapered path with a hot→cool vertical gradient, + * its centreline waving and flickering via a few fractal-noise samples (see [OGFlameSpec]) — no per-pixel + * work, no shader. Overlay it in a `Box`; it is transparent between and above the tongues. For a candle, + * use the one-tongue [OGFlame.CANDLE] preset in a narrow box. + */ +@Composable +fun OGFlameView( + spec: OGFlameSpec, + modifier: Modifier = Modifier, + playing: Boolean = true, + windOverride: Float? = null, +) { + val s = remember(spec, windOverride) { + (if (windOverride != null) spec.copy(wind = windOverride) else spec).sanitized() + } + val core = remember(s) { s.coreColor.toSpecColor() ?: Color(0xFFFFD24D) } + val tip = remember(s) { s.tipColor.toSpecColor() ?: Color(0xFFFF4A1C) } + + var elapsed by remember(s) { mutableStateOf(0f) } + LaunchedEffect(s, playing) { + if (!playing) return@LaunchedEffect + val start = withFrameMillis { it } + while (true) withFrameMillis { elapsed = (it - start).toFloat() } + } + + Canvas(modifier) { + drawOGFlameFrame(s, elapsed / 1000f, core, tip) + } +} + +/** + * Draw one frame of a [spec]'s fire at [timeSec] with its resolved [core] (base) and [tip] colours, into + * the current [DrawScope]. The per-frame body shared by the live [OGFlameView] and any off-screen baker — + * one code path, so an export matches the preview. Each tongue is a tapered path around its waving spine, + * filled with a vertical base→tip gradient. + */ +internal fun DrawScope.drawOGFlameFrame(spec: OGFlameSpec, timeSec: Float, core: Color, tip: Color) { + val w = size.width; val h = size.height + if (w <= 0f || h <= 0f) return + + val mid = lerpColor(core, tip, 0.55f) + flameTongues(spec, timeSec).forEachIndexed { k, tongue -> + val spine = tongue.spine + if (spine.size < 2) return@forEachIndexed + val baseHalf = (tongue.halfWidth * w).coerceAtLeast(0.5f) + val n = spine.size - 1 + + // Per-point torn-edge displacement, sampled once per spine point and reused by both passes. + // Kelvin-Helmholtz structured (see [khEdgeNoise]) rather than plain random noise. + val edgeNoise = FloatArray(n + 1) { i -> + khEdgeNoise(i.toFloat() / n, timeSec, k, spec) + } + + val cx = spine[0].x * w + val baseYPx = spine[0].y * h + val tipYPx = spine[n].y * h + + // Pass A — outer haze: full belly width + ragged x-jittered edges, translucent tip-colour wash. + val haze = tonguePath(spine, edgeNoise, baseHalf, w, h, widthFactor = 1f, jitter = true) + drawPath( + haze, + brush = Brush.linearGradient( + colorStops = arrayOf( + 0f to tip.copy(alpha = 0f), + 0.10f to tip.copy(alpha = tip.alpha * tongue.alpha * 0.25f), + 0.45f to tip.copy(alpha = tip.alpha * tongue.alpha * 0.65f), + 1f to tip.copy(alpha = 0f), + ), + start = Offset(cx, baseYPx), + end = Offset(cx, tipYPx), + ), + ) + + // Pass B — inner bright core: slimmer, cleaner edge, brightness pulsing with the flicker. + val coreAlpha = ( + tongue.alpha * (0.80f + 0.20f * (tongue.flickerPulse - 1f) / spec.flicker.coerceAtLeast(0.001f)) + ).coerceIn(0f, 1f) + val inner = tonguePath(spine, edgeNoise, baseHalf, w, h, widthFactor = 0.60f, jitter = false) + drawPath( + inner, + brush = Brush.linearGradient( + colorStops = arrayOf( + 0f to core.copy(alpha = 0f), + 0.10f to core.copy(alpha = core.alpha * coreAlpha * 0.35f), + 0.28f to core.copy(alpha = core.alpha * coreAlpha), + 0.65f to mid.copy(alpha = mid.alpha * coreAlpha * 0.85f), + 1f to tip.copy(alpha = 0f), + ), + start = Offset(cx, baseYPx), + end = Offset(cx, tipYPx), + ), + ) + } +} + +// KH mode tables (allocated once): subharmonic / fundamental / first harmonic of the dominant +// roll-up wavelength — vortex pairing makes real plumes multi-scale — with each mode riding +// tip-ward at a slightly different drift speed. +private val KH_FREQ_MUL = floatArrayOf(0.6f, 1f, 1.7f) +private val KH_BASE_AMP = floatArrayOf(0.42f, 0.50f, 0.30f) +private val KH_DRIFT_MUL = floatArrayOf(0.8f, 1.1f, 1.45f) + +/** + * **Kelvin-Helmholtz structured edge displacement** at height [s] (`0` = base, `1` = tip) for tongue + * [k] at [timeSec], roughly in `[-1, 1]`. + * + * Real flame edges are not randomly noisy: the velocity shear between the hot rising column and the + * still ambient air rolls the interface up into a train of **Kelvin-Helmholtz vortices**. The linear + * instability grows as `σ ∝ k·|Δv|` — so a faster flame rolls up at a shorter wavelength — and each + * perturbation grows ~exponentially with height until nonlinear saturation. We model that with three + * travelling spatial modes (subharmonic / fundamental / first harmonic of the dominant KH wavelength, + * which shrinks with [OGFlameSpec.flickerSpeed]), each with an `exp`-growth height envelope saturating + * at 1, drifting tip-ward with the gas at ~[OGFlameSpec.swaySpeed], plus a small fractal-noise detail + * layer riding at 30% of the KH amplitude. Deterministic from the spec seed. + */ +private fun khEdgeNoise(s: Float, timeSec: Float, k: Int, spec: OGFlameSpec): Float { + val twoPi = 2f * PI.toFloat() + // Dominant KH mode: faster flame (stronger shear) → shorter roll-up wavelength → higher frequency. + val fDom = (spec.flickerSpeed * 1.5f + 1f) + val drift = spec.swaySpeed * twoPi + + val growth = 3.5f + // Exponential instability growth with height, saturating at 1 (nonlinear roll-up limit). + val envelope = min(1f, exp(growth * s) - 1f) + + var kh = 0f + var ampSum = 0f + for (m in 0..2) { + val phase = OGFxMath.hash01(m * 19 + k * 3 + 5, spec.seed + 77) * twoPi + val a = KH_BASE_AMP[m] * envelope + kh += a * sin(s * fDom * KH_FREQ_MUL[m] * twoPi - timeSec * drift * KH_DRIFT_MUL[m] + phase) + ampSum += a + } + // Fine turbulent detail at 30% of the KH amplitude — texture, not structure. + val detail = 0.3f * ampSum * OGFxNoise.fbm( + s * 4.5f + timeSec * spec.swaySpeed * 1.3f + k * 3.1f, + timeSec * spec.flickerSpeed * 0.4f + k * 0.7f, + 2, spec.seed + 77, + ) + return ((kh + detail) * 0.8f).coerceIn(-1.2f, 1.2f) +} + +/** + * Build one tongue's tapered outline: up the left edge (base → tip), then down the right edge. Width + * follows the [bellTaper] belly profile scaled by [widthFactor]; when [jitter] is set the per-point + * [edgeNoise] also shears the edges sideways (growing toward the tip) for a torn, turbulent silhouette. + */ +private fun tonguePath( + spine: List, + edgeNoise: FloatArray, + baseHalf: Float, + w: Float, + h: Float, + widthFactor: Float, + jitter: Boolean, +): Path { + val n = spine.size - 1 + val path = Path() + for (i in 0..n) { + val s = i.toFloat() / n + val hw = baseHalf * bellTaper(s, edgeNoise[i]) * widthFactor + val jx = if (jitter) edgeNoise[i] * s * s * baseHalf * 0.20f else 0f + val x = spine[i].x * w - hw + jx + val y = spine[i].y * h + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + for (i in n downTo 0) { + val s = i.toFloat() / n + val hw = baseHalf * bellTaper(s, edgeNoise[i]) * widthFactor + val jx = if (jitter) edgeNoise[i] * s * s * baseHalf * 0.20f else 0f + path.lineTo(spine[i].x * w + hw + jx, spine[i].y * h) + } + path.close() + return path +} + +/** + * Half-width factor along a tongue with a **buoyancy belly**: narrowest at the root (dense fuel rising + * in a tight column), swelling to full width ~38% up — where buoyant acceleration and ambient mixing + * entrain the most volume — then smoothstepping down to a sharp tip as the gas cools. [noise] tears + * the profile more and more toward the tip for a ragged, turbulent crown. + */ +private fun bellTaper(s: Float, noise: Float): Float { + val base = if (s < 0.38f) { + // Root taper: smooth teardrop root curving to 0 width at s=0 so the fire base is organic, never a cut line! + val rootTaper = if (s < 0.16f) { + val u = s / 0.16f + u * u * (3f - 2f * u) // smoothstep ramp from 0 at s=0 to 1 at s=0.16 + } else 1f + (0.4f + (s / 0.38f) * 0.6f) * rootTaper + } else { + val t = (s - 0.38f) / 0.62f + val smooth = t * t * (3f - 2f * t) // smoothstep + 1f - smooth + } + return (base + noise * s * s * 0.35f).coerceIn(0f, 1.4f) +} + +private fun lerpColor(a: Color, b: Color, t: Float): Color = Color( + red = a.red + (b.red - a.red) * t, + green = a.green + (b.green - a.green) * t, + blue = a.blue + (b.blue - a.blue) * t, + alpha = a.alpha + (b.alpha - a.alpha) * t, +) diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFluidStreamSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFluidStreamSpec.kt new file mode 100644 index 0000000..6746d39 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFluidStreamSpec.kt @@ -0,0 +1,68 @@ +package com.solidkey.painpoints.fx + +import com.solidkey.painpoints.ai.OGAiVector +import com.solidkey.painpoints.ai.clamp01 +import com.solidkey.painpoints.shape.OGPoint + +/** + * A **directional fluid stream** — a high-pressure jet of liquid (water/flood/lava) that shoots from + * origin to target, featuring animated overlapping sine-wave currents, splashing particle droplets at the + * impact site, and foaming edges. + */ +@kotlinx.serialization.Serializable +data class OGFluidStreamSpec( + val name: String? = null, + val x1: Float = 0.5f, + val y1: Float = 0.9f, + val x2: Float = 0.5f, + val y2: Float = 0.1f, + /** Width of the stream as a fraction of the shorter screen side. */ + val width: Float = 0.08f, + val coreColor: String = "#81D4FA", + val edgeColor: String = "#01579B", + val foamColor: String = "#FFFFFF", + /** How turbulent the flow is (0..1). */ + val turbulence: Float = 0.5f, + /** Duration of the blast in milliseconds. */ + val durationMs: Float = 800f, + /** Number of splashing droplet particles at the target. */ + val splashParticles: Int = 20, + val loop: Boolean = false, + val seed: Int = 0, +) { + fun sanitized() = copy( + width = width.coerceIn(0.01f, 0.5f), + turbulence = turbulence.coerceIn(0f, 1f), + splashParticles = splashParticles.coerceIn(0, 100), + ) +} + +internal fun OGFluidStreamSpec.endpointA() = OGPoint(clamp01(x1), clamp01(y1)) +internal fun OGFluidStreamSpec.endpointB() = OGPoint(clamp01(x2), clamp01(y2)) + +object OGFluidStreams { + val json get() = OGAiVector.json + + fun decodeSpec(text: String): OGFluidStreamSpec = + json.decodeFromString(OGFluidStreamSpec.serializer(), OGAiVector.extractJson(text)) + + fun encode(spec: OGFluidStreamSpec): String = json.encodeToString(OGFluidStreamSpec.serializer(), spec) + + fun preset(name: String): OGFluidStreamSpec? = presets[name.lowercase()] + + /** A high-pressure cinematic water flood stream. */ + val FLOOD_BLAST = OGFluidStreamSpec( + name = "flood_blast", x1 = 0.5f, y1 = 0.9f, x2 = 0.5f, y2 = 0.2f, + width = 0.12f, coreColor = "#4FC3F7", edgeColor = "#0D47A1", foamColor = "#E1F5FE", + turbulence = 0.75f, durationMs = 1200f, splashParticles = 40, + ) + + /** A corrosive acid stream. */ + val ACID_SPIT = OGFluidStreamSpec( + name = "acid_spit", x1 = 0.2f, y1 = 0.8f, x2 = 0.8f, y2 = 0.2f, + width = 0.06f, coreColor = "#CCFF90", edgeColor = "#33691E", foamColor = "#F4FF81", + turbulence = 0.4f, durationMs = 700f, splashParticles = 25, + ) + + val presets = listOf(FLOOD_BLAST, ACID_SPIT).associateBy { it.name!! } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFluidStreamView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFluidStreamView.kt new file mode 100644 index 0000000..e6929c2 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFluidStreamView.kt @@ -0,0 +1,205 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import com.solidkey.painpoints.ai.toSpecColor +import com.solidkey.painpoints.shape.OGPoint +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +@Composable +fun OGFluidStreamView( + spec: OGFluidStreamSpec, + modifier: Modifier = Modifier, + playing: Boolean = true, +) { + val s = remember(spec) { spec.sanitized() } + val coreColor = remember(s) { s.coreColor.toSpecColor() ?: Color(0xFF81D4FA) } + val edgeColor = remember(s) { s.edgeColor.toSpecColor() ?: Color(0xFF01579B) } + val foamColor = remember(s) { s.foamColor.toSpecColor() ?: Color(0xFFFFFFFF) } + + var elapsed by remember(s) { mutableStateOf(0f) } + LaunchedEffect(s, playing) { + if (!playing) return@LaunchedEffect + val start = withFrameMillis { it } + while (true) withFrameMillis { elapsed = (it - start).toFloat() } + } + + Canvas(modifier.clipToBounds()) { + drawOGFluidStreamFrame(s, elapsed, coreColor, edgeColor, foamColor) + } +} + +internal fun DrawScope.drawOGFluidStreamFrame( + spec: OGFluidStreamSpec, + elapsedMs: Float, + coreColor: Color, + edgeColor: Color, + foamColor: Color, +) { + val cycle = spec.durationMs + if (cycle <= 0f) return + val idx = if (spec.loop) (elapsedMs / cycle).toInt() else 0 + val t = if (spec.loop) elapsedMs - idx * cycle else elapsedMs.coerceIn(0f, cycle) + + val p = (t / cycle).coerceIn(0f, 1f) + if (p <= 0f || p >= 1f) return + + // Blast progression: starts at x1,y1 and extends to x2,y2. + val w = size.width; val h = size.height + val minSide = minOf(w, h) + + val a = OGPoint(spec.endpointA().x * w, spec.endpointA().y * h) + val b = OGPoint(spec.endpointB().x * w, spec.endpointB().y * h) + + val dx = b.x - a.x; val dy = b.y - a.y + val len = sqrt(dx * dx + dy * dy) + if (len < 1f) return + + val nx = dx / len; val ny = dy / len + val px = -ny; val py = nx // Perpendicular + + // Advance progress with a fast shoot out, slow dwell, and fast retract + val streamProgress = if (p < 0.2f) { + p / 0.2f + } else if (p < 0.8f) { + 1f + } else { + 1f - (p - 0.8f) / 0.2f + } + val currentLength = len * streamProgress + val currentTip = OGPoint(a.x + nx * currentLength, a.y + ny * currentLength) + + val baseW = minSide * spec.width + val timeSec = elapsedMs / 1000f + + // Draw the fluid column by constructing a path with animated sine waves along the perpendicular + val steps = 20 + val streamPath = Path() + val streamPathInner = Path() + + for (i in 0..steps) { + val frac = i.toFloat() / steps + val ptX = a.x + nx * (currentLength * frac) + val ptY = a.y + ny * (currentLength * frac) + + // Taper at origin and tip + val taper = (1f - (frac - 0.5f) * (frac - 0.5f) * 4f).coerceAtLeast(0f) + val widthAtPt = baseW * taper + + // Flowing sine waves + val phase1 = timeSec * 15f + frac * 8f + val phase2 = timeSec * 22f + frac * 13f + + val waveOffset = (sin(phase1) * 0.5f + cos(phase2) * 0.5f) * spec.turbulence * widthAtPt * 0.4f + + val edgeX = ptX + px * (widthAtPt * 0.5f + waveOffset) + val edgeY = ptY + py * (widthAtPt * 0.5f + waveOffset) + + if (i == 0) streamPath.moveTo(edgeX, edgeY) else streamPath.lineTo(edgeX, edgeY) + } + + for (i in steps downTo 0) { + val frac = i.toFloat() / steps + val ptX = a.x + nx * (currentLength * frac) + val ptY = a.y + ny * (currentLength * frac) + + val taper = (1f - (frac - 0.5f) * (frac - 0.5f) * 4f).coerceAtLeast(0f) + val widthAtPt = baseW * taper + + val phase1 = timeSec * 18f - frac * 9f + val phase2 = timeSec * 25f - frac * 11f + + val waveOffset = (sin(phase1) * 0.5f + cos(phase2) * 0.5f) * spec.turbulence * widthAtPt * 0.4f + + val edgeX = ptX - px * (widthAtPt * 0.5f + waveOffset) + val edgeY = ptY - py * (widthAtPt * 0.5f + waveOffset) + + streamPath.lineTo(edgeX, edgeY) + } + streamPath.close() + + // Draw outer edge and inner core + val alphaScale = (streamProgress * 1.5f).coerceAtMost(1f) + drawPath(streamPath, edgeColor.copy(alpha = 0.9f * alphaScale)) + drawPath(streamPath, coreColor.copy(alpha = 0.9f * alphaScale), style = Stroke(width = baseW * 0.4f, cap = StrokeCap.Round, join = StrokeJoin.Round)) + + // Foam streaks along the stream + val foamRng = kotlin.random.Random(spec.seed) + for (i in 0..4) { + val startFrac = foamRng.nextFloat() * 0.8f + val lengthFrac = 0.1f + foamRng.nextFloat() * 0.2f + if (startFrac > streamProgress) continue + + val fStart = startFrac + val fEnd = minOf(startFrac + lengthFrac, streamProgress) + + val lateralOffset = (foamRng.nextFloat() - 0.5f) * 0.8f + + val fPath = Path() + val fSteps = 5 + for (j in 0..fSteps) { + val f = fStart + (fEnd - fStart) * (j.toFloat() / fSteps) + val ptX = a.x + nx * (currentLength * (f / streamProgress)) + val ptY = a.y + ny * (currentLength * (f / streamProgress)) + + val taper = (1f - (f - 0.5f) * (f - 0.5f) * 4f).coerceAtLeast(0f) + val widthAtPt = baseW * taper + val streamLateral = lateralOffset * widthAtPt + + // moving foam + val flowPhase = timeSec * 10f + val flowX = nx * (flowPhase % 1f) * len * 0.1f + val flowY = ny * (flowPhase % 1f) * len * 0.1f + + val finalX = ptX + px * streamLateral - flowX + val finalY = ptY + py * streamLateral - flowY + + if (j == 0) fPath.moveTo(finalX, finalY) else fPath.lineTo(finalX, finalY) + } + drawPath(fPath, foamColor.copy(alpha = 0.8f * alphaScale), style = Stroke(width = baseW * 0.1f, cap = StrokeCap.Round)) + } + + // Splashing particles at the impact site + if (streamProgress > 0.8f && spec.splashParticles > 0) { + val splashP = (p - 0.2f) / 0.6f // normalize splash phase during the dwell + val splashRng = kotlin.random.Random(spec.seed + idx) + + for (i in 0 until spec.splashParticles) { + val angle = splashRng.nextFloat() * 2f * PI.toFloat() + val speed = (0.5f + splashRng.nextFloat()) * baseW * 8f + val life = 0.3f + splashRng.nextFloat() * 0.7f + val particleT = (splashP + splashRng.nextFloat()) % 1f // loop during dwell + + if (particleT < life) { + val px = currentTip.x + cos(angle) * speed * particleT + val py = currentTip.y + sin(angle) * speed * particleT + (particleT * particleT * baseW * 15f) // simple gravity + + val pSize = baseW * 0.15f * (1f - (particleT / life)) + val isFoam = splashRng.nextBoolean() + drawCircle( + color = if (isFoam) foamColor.copy(alpha = 0.9f) else edgeColor.copy(alpha = 0.9f), + radius = pSize, + center = Offset(px, py) + ) + } + } + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFxModifiers.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFxModifiers.kt index 53d3f7f..ef50a9b 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFxModifiers.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGFxModifiers.kt @@ -7,13 +7,20 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlurEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin /** * Modifier FX for the `com.solidkey.painpoints.fx` action family. Each captures the composable's drawn @@ -163,3 +170,123 @@ fun Modifier.ogBloom( drawContent() // the crisp current frame on top } } + +/** + * **Camera Trauma / Cinematic Screen Shake** — Applies quadratic screen shake (Squirrel Eiserloh's + * game-feel standard: `shake = trauma²`) to any composable. Trauma decay, high-frequency rotational + * and translational displacement gives authentic anime / blockbuster impact feel. + * + * [trauma] is `0..1` (values > 0 trigger shake; decaying each frame or animated via `Animatable`). + * [timeSec] drives the continuous deterministic wobble (pass animation progress or elapsed seconds). + * [maxTranslationPx] is the peak horizontal/vertical displacement at trauma=1. + * [maxRotationDeg] is the peak rotational tilt in degrees at trauma=1. + * [frequency] controls the vibration speed (default ~24Hz). + * [seed] decorrelates distinct screen instances. + */ +fun Modifier.ogCameraTrauma( + trauma: Float, + timeSec: Float = 0f, + maxTranslationPx: Float = 24f, + maxRotationDeg: Float = 3.5f, + frequency: Float = 24f, + seed: Int = 42, +): Modifier = graphicsLayer { + val t = trauma.coerceIn(0f, 1f) + if (t > 0.001f) { + val shake = t * t // Non-linear falloff: low trauma feels subtle, high trauma feels violent + val phase = timeSec * frequency * 2f * PI.toFloat() + val rnd1 = sin(phase + seed * 1.7f) + val rnd2 = cos(phase * 1.25f + seed * 3.1f) + val rnd3 = sin(phase * 0.85f + seed * 5.3f) + + translationX = rnd1 * maxTranslationPx * shake + translationY = rnd2 * maxTranslationPx * 0.75f * shake + rotationZ = rnd3 * maxRotationDeg * shake + } +} + +/** + * **Electrocute / High-Voltage Plasma Arc** modifier — applies electrical spasm jitter, + * inverted/overexposed plasma flash, and branching electric arcing over the composable's bounds. + * + * [intensity] is `0..1` (driving the flash and arc density). + * [color] is the electric arc corona colour (default bright cyan `#80D8FF`). + * [seed] decorrelates the fractal arcs. + */ +fun Modifier.ogElectrocute( + intensity: Float, + color: Color = Color(0xFF80D8FF), + seed: Int = 42, +): Modifier = composed { + val i = intensity.coerceIn(0f, 1f) + if (i <= 0.001f) return@composed this + drawWithContent { + drawContent() + if (i > 0.02f) { + // High-voltage electric corona wash + drawRect(color = color.copy(alpha = i * 0.28f), blendMode = BlendMode.Plus) + // Branching electrical spark discharge arcs around the perimeter + val w = size.width; val h = size.height + if (w > 0f && h > 0f) { + val rng = kotlin.random.Random(seed + (i * 200).toInt()) + val arcCount = (3 + (i * 4).toInt()).coerceAtMost(8) + for (a in 0 until arcCount) { + val startX = rng.nextFloat() * w + val startY = rng.nextFloat() * h + val path = Path().apply { + moveTo(startX, startY) + var curX = startX; var curY = startY + repeat(4) { + curX += (rng.nextFloat() - 0.5f) * w * 0.35f + curY += (rng.nextFloat() - 0.5f) * h * 0.35f + lineTo(curX, curY) + } + } + drawPath(path, color = Color.White, style = Stroke(width = 2.5f, cap = StrokeCap.Round), blendMode = BlendMode.Plus) + drawPath(path, color = color, style = Stroke(width = 5.5f, cap = StrokeCap.Round), blendMode = BlendMode.Plus) + } + } + } + } +} + +/** + * **Underwater Caustic Light Web** modifier — draws shimmering solar refraction ribbons across + * submerged content, simulating deep underwater lighting. + * + * [timeSec] drives the continuous caustic undulation. + * [depth] is `0..1` (controlling caustic brightness and water murk). + * [color] is the caustic refraction light colour. + */ +fun Modifier.ogWaterCaustics( + timeSec: Float, + depth: Float = 0.5f, + color: Color = Color(0xFF81D4FA), +): Modifier = composed { + val d = depth.coerceIn(0f, 1f) + if (d <= 0.001f) return@composed this + drawWithContent { + drawContent() + val w = size.width; val h = size.height + if (w <= 0f || h <= 0f) return@drawWithContent + val ribbonCount = 5 + for (r in 0 until ribbonCount) { + val yBase = (r.toFloat() / ribbonCount) * h + sin(timeSec * 3f + r) * 12f + val path = Path().apply { + moveTo(0f, yBase) + val steps = 8 + for (s in 1..steps) { + val x = (s.toFloat() / steps) * w + val y = yBase + sin(x * 0.03f + timeSec * 4f + r * 1.5f) * 10f + lineTo(x, y) + } + } + drawPath( + path, + color = color.copy(alpha = d * 0.35f), + style = Stroke(width = 6f, cap = StrokeCap.Round), + blendMode = BlendMode.Plus, + ) + } + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightningSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightningSpec.kt index c8b8831..347f5ff 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightningSpec.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightningSpec.kt @@ -55,6 +55,10 @@ data class OGLightningSpec( val loop: Boolean = true, /** Seed for the deterministic jag/branches. */ val seed: Int = 0, + /** Whether the bolt thickness tapers to a sharp point at the end (removes worm-like look). */ + val taper: Boolean = true, + /** Size of the flash bloom at the target impact point (as a fraction of shorter side, 0 = none). */ + val flashRadius: Float = 0.08f, ) { /** One strike cycle (strike + decay + gap) in ms. */ val cycleMs: Float get() = strikeMs.coerceAtLeast(0f) + decayMs.coerceAtLeast(0f) + gapMs.coerceAtLeast(0f) diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightningView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightningView.kt index 1a49ac3..ad9f03d 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightningView.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightningView.kt @@ -75,38 +75,58 @@ internal fun DrawScope.drawOGLightningFrame( val main = lightningChannel(a, b, short * spec.jaggedness, spec.detail, spec.seed + strike.index * 7919) val baseW = short * spec.width - drawChannel(polylinePath(main), baseW, spec.glow, strike.alpha, color, coreColor, glowColor) + drawChannel(main, baseW, spec.glow, strike.alpha, color, coreColor, glowColor, spec.taper) for (br in lightningBranches(main, spec, strike.index, short)) { - if (br.size >= 2) drawChannel(polylinePath(br), baseW * 0.5f, spec.glow, strike.alpha * 0.9f, color, coreColor, glowColor) + if (br.size >= 2) drawChannel(br, baseW * 0.5f, spec.glow, strike.alpha * 0.9f, color, coreColor, glowColor, spec.taper) + } + + // Draw cinematic flash impact + if (spec.flashRadius > 0f && strike.alpha > 0.1f) { + val flashR = spec.flashRadius * short * strike.alpha + drawCircle(glowColor.copy(alpha = 0.4f * strike.alpha), radius = flashR * 2f, center = androidx.compose.ui.geometry.Offset(b.x, b.y), blendMode = BlendMode.Plus) + drawCircle(coreColor.copy(alpha = 0.9f * strike.alpha), radius = flashR * 0.6f, center = androidx.compose.ui.geometry.Offset(b.x, b.y), blendMode = BlendMode.Plus) } } -private fun polylinePath(pts: List): Path { - val p = Path() - if (pts.isEmpty()) return p - p.moveTo(pts[0].x, pts[0].y) - for (i in 1 until pts.size) p.lineTo(pts[i].x, pts[i].y) - return p -} - -/** Stroke a bolt channel as a layered additive bloom + a white-hot core. */ +/** Stroke a bolt channel as a layered additive bloom + a white-hot core. If tapered, draws segments. */ private fun DrawScope.drawChannel( - path: Path, + pts: List, baseW: Float, glow: Float, alpha: Float, color: Color, coreColor: Color, glowColor: Color, + taper: Boolean ) { - if (glow > 0f) { - drawPath(path, glowColor, alpha = 0.12f * glow * alpha, - style = Stroke(width = baseW * 3f, cap = StrokeCap.Round, join = StrokeJoin.Round), blendMode = BlendMode.Plus) - drawPath(path, glowColor, alpha = 0.2f * glow * alpha, - style = Stroke(width = baseW * 1.7f, cap = StrokeCap.Round, join = StrokeJoin.Round), blendMode = BlendMode.Plus) + if (pts.size < 2) return + if (!taper) { + val p = Path() + p.moveTo(pts[0].x, pts[0].y) + for (i in 1 until pts.size) p.lineTo(pts[i].x, pts[i].y) + if (glow > 0f) { + drawPath(p, glowColor, alpha = 0.12f * glow * alpha, + style = Stroke(width = baseW * 3f, cap = StrokeCap.Round, join = StrokeJoin.Round), blendMode = BlendMode.Plus) + drawPath(p, glowColor, alpha = 0.2f * glow * alpha, + style = Stroke(width = baseW * 1.7f, cap = StrokeCap.Round, join = StrokeJoin.Round), blendMode = BlendMode.Plus) + } + drawPath(p, color, alpha = 0.9f * alpha, + style = Stroke(width = baseW, cap = StrokeCap.Round, join = StrokeJoin.Round), blendMode = BlendMode.Plus) + drawPath(p, coreColor, alpha = alpha, + style = Stroke(width = baseW * 0.4f, cap = StrokeCap.Round, join = StrokeJoin.Round), blendMode = BlendMode.Plus) + } else { + val total = pts.size - 1 + for (i in 0 until total) { + val tFrac = 1f - (i.toFloat() / total) + val w = baseW * (0.1f + 0.9f * tFrac) + val p1 = androidx.compose.ui.geometry.Offset(pts[i].x, pts[i].y) + val p2 = androidx.compose.ui.geometry.Offset(pts[i+1].x, pts[i+1].y) + if (glow > 0f) { + drawLine(glowColor, p1, p2, strokeWidth = w * 3f, cap = StrokeCap.Round, alpha = 0.12f * glow * alpha, blendMode = BlendMode.Plus) + drawLine(glowColor, p1, p2, strokeWidth = w * 1.7f, cap = StrokeCap.Round, alpha = 0.2f * glow * alpha, blendMode = BlendMode.Plus) + } + drawLine(color, p1, p2, strokeWidth = w, cap = StrokeCap.Round, alpha = 0.9f * alpha, blendMode = BlendMode.Plus) + drawLine(coreColor, p1, p2, strokeWidth = w * 0.4f, cap = StrokeCap.Round, alpha = alpha, blendMode = BlendMode.Plus) + } } - drawPath(path, color, alpha = 0.9f * alpha, - style = Stroke(width = baseW, cap = StrokeCap.Round, join = StrokeJoin.Round), blendMode = BlendMode.Plus) - drawPath(path, coreColor, alpha = alpha, - style = Stroke(width = baseW * 0.4f, cap = StrokeCap.Round, join = StrokeJoin.Round), blendMode = BlendMode.Plus) } diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightnings.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightnings.kt index 4506d15..9e0f3d4 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightnings.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGLightnings.kt @@ -59,8 +59,16 @@ object OGLightnings { name = "spark", x1 = 0.08f, y1 = 0.5f, x2 = 0.92f, y2 = 0.46f, width = 0.01f, jaggedness = 0.1f, detail = 5, branches = 3, branchLength = 0.3f, color = "#FFF59D", coreColor = "#FFFFFF", glowColor = "#FFE082", glow = 1f, - strikeMs = 60f, decayMs = 150f, gapMs = 300f, seed = 9, + strikeMs = 60f, decayMs = 150f, gapMs = 300f, seed = 9, taper = true, flashRadius = 0.04f, ) - val presets: Map = listOf(BOLT, STORM, SPARK).associateBy { it.name!! } + /** A highly cinematic, tapering, branched massive thunder strike with intense impact flash. */ + val THUNDER = OGLightningSpec( + name = "thunder", x1 = 0.5f, y1 = 0.01f, x2 = 0.45f, y2 = 0.98f, + width = 0.035f, jaggedness = 0.25f, detail = 8, branches = 12, branchLength = 0.45f, + color = "#81D4FA", coreColor = "#FFFFFF", glowColor = "#03A9F4", glow = 1.2f, + strikeMs = 120f, decayMs = 350f, gapMs = 600f, seed = 14, taper = true, flashRadius = 0.15f, + ) + + val presets: Map = listOf(BOLT, STORM, SPARK, THUNDER).associateBy { it.name!! } } diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGRipple.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGRipple.kt new file mode 100644 index 0000000..32c3280 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGRipple.kt @@ -0,0 +1,81 @@ +package com.solidkey.painpoints.fx + +import com.solidkey.painpoints.ai.OGAiVector + +/** + * Codec + presets for [OGRippleSpec]. + * + * Presets: + * - [RAIN] — realistic rain on still water, tight rings, crown droplets, deep trough + * - [POND] — wider, slower, fewer rings — calm Ghibli pond + * - [PUDDLE] — shallow perspective, fast decay, heavy squash + */ +object OGRipple { + val json get() = OGAiVector.json + + fun decodeSpec(text: String): OGRippleSpec = + json.decodeFromString(OGRippleSpec.serializer(), OGAiVector.extractJson(text)) + + fun decodeSpecOrNull(text: String): OGRippleSpec? = runCatching { decodeSpec(text) }.getOrNull() + + fun encode(spec: OGRippleSpec): String = json.encodeToString(OGRippleSpec.serializer(), spec) + + fun preset(name: String): OGRippleSpec? = presets[name.lowercase()] + + fun ripplePrompt(instruction: String): String = """ + You output ONLY a JSON object describing a raindrop ripple / capillary-wave effect for the + KMPMedia library. Impacts are supplied separately at runtime; this spec describes the look of + each ring set. + + propagationSpeed: fraction of min(W,H) per second (Worthington dispersion, exponent 0.65). + decayLambda: exponential ring fade rate (higher = faster fade). + maxRadius: how far rings travel before culling, fraction of min(W,H). + squash: 1.0 = circles, 0.5 = 2:1 ground-plane ovals (perspective). + crownDroplets: show ballistic streak droplets on impact (true/false). + troughColor: dark refraction colour for 3D water depression. + troughDepth: 0..1 strength of the refraction trough (0=flat, 0.7=rich water depth). + reboundJet: central Worthington bead at impact. + + Fields (all optional): name, propagationSpeed, decayLambda, maxRadius, secondaryRings, + ringSpacing, color, crestColor, lineWidth, squash, crownDroplets, crownCount, seed, + troughColor, troughDepth, reboundJet. + + Output ONLY the JSON object. + + $instruction + """.trimIndent() + + // ── Presets ────────────────────────────────────────────────────────────────────────────────── + + /** Realistic rain — crisp rings, ballistic crown, deep refraction trough. */ + val RAIN = OGRippleSpec( + name = "rain", + propagationSpeed = 0.38f, decayLambda = 3.0f, maxRadius = 0.52f, + secondaryRings = 4, ringSpacing = 0.050f, + color = "#4FC3F7", crestColor = "#FFFFFF", lineWidth = 0.0075f, + squash = 0.65f, crownDroplets = true, crownCount = 12, + troughColor = "#051D33", troughDepth = 0.70f, reboundJet = true, + ) + + /** Calm Ghibli pond — slow wide rings, subtle trough, no crown. */ + val POND = OGRippleSpec( + name = "pond", + propagationSpeed = 0.22f, decayLambda = 1.7f, maxRadius = 0.68f, + secondaryRings = 6, ringSpacing = 0.065f, + color = "#81D4FA", crestColor = "#E1F5FE", lineWidth = 0.0055f, + squash = 0.72f, crownDroplets = false, crownCount = 8, + troughColor = "#002538", troughDepth = 0.55f, reboundJet = false, + ) + + /** Shallow ground puddle — fast, heavy squash, dark earthy trough. */ + val PUDDLE = OGRippleSpec( + name = "puddle", + propagationSpeed = 0.50f, decayLambda = 4.2f, maxRadius = 0.42f, + secondaryRings = 3, ringSpacing = 0.042f, + color = "#90CAF9", crestColor = "#FFFFFF", lineWidth = 0.0085f, + squash = 0.40f, crownDroplets = true, crownCount = 14, + troughColor = "#1A1510", troughDepth = 0.80f, reboundJet = true, + ) + + val presets: Map = listOf(RAIN, POND, PUDDLE).associateBy { it.name!! } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGRippleSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGRippleSpec.kt new file mode 100644 index 0000000..3c514fd --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGRippleSpec.kt @@ -0,0 +1,77 @@ +package com.solidkey.painpoints.fx + +import kotlinx.serialization.Serializable + +/** + * Describes a raindrop-ripple / capillary-wave effect (Worthington 1908; Fournier & Reeves 1986). + * + * Each ripple impact spawns: + * 1. A **primary ring** that expands with Andersen dispersion deceleration: + * r(t) = [propagationSpeed] · t^0.65 · min(W,H) + * 2. **Secondary rings** spaced [ringSpacing] behind the leading crest, grouped with a + * Gaussian wave-packet amplitude envelope for natural capillary dispersion. + * 3. An **amplitude envelope** that decays exponentially: A(t) = A₀ · e^(-[decayLambda]·t). + * 4. Optional **Worthington splash crown** — ballistic streak droplets + a central rebound bead. + * 5. **3D meniscus rendering**: dark refraction trough + diffuse water body + specular crest ridge. + * + * Rings are stroked ellipses squashed by [squash] for perspective (1.0 = circle, 0.6 = ground plane). + * The effect is purely passive — impacts are supplied as [OGRippleEvent]s; see [OGRippleState] + * and [rememberOGRippleState] for the interactive-tap wiring. + * + * Pure `commonMain` — zero shaders, vector stroked ellipses only. + * + * Presets and codec on [OGRipple]. Rendered by [OGRippleView]. + */ +@Serializable +data class OGRippleSpec( + val name: String? = null, + /** Ring expansion speed (fraction of min(W,H) per second^0.65). */ + val propagationSpeed: Float = 0.38f, + /** Exponential amplitude decay rate λ. Higher = rings fade faster. */ + val decayLambda: Float = 3.2f, + /** Maximum ring radius as fraction of min(W,H) before the ring is culled. */ + val maxRadius: Float = 0.52f, + /** Number of secondary ripple rings trailing the primary crest. */ + val secondaryRings: Int = 4, + /** Radial gap between secondary rings as fraction of min(W,H). */ + val ringSpacing: Float = 0.055f, + /** Ring stroke colour (HEX). */ + val color: String = "#4FC3F7", + /** Leading crest highlight colour (brighter, short-lived). */ + val crestColor: String = "#FFFFFF", + /** Ring stroke width as fraction of min(W,H). */ + val lineWidth: Float = 0.007f, + /** Vertical squash for perspective: 1.0 = perfect circle, 0.5 = 2:1 ground-plane oval. */ + val squash: Float = 0.65f, + /** Show Worthington splash-crown droplets at impact (t < 150ms). */ + val crownDroplets: Boolean = true, + /** Number of crown droplet arcs (ignored when [crownDroplets] = false). */ + val crownCount: Int = 12, + val seed: Int = 0, + /** Dark refraction trough shadow colour — creates the 3D water depression under the crest. */ + val troughColor: String = "#051D33", + /** Relative strength of the 3D refraction trough (`0` = disabled, `0.7` = rich water depth). */ + val troughDepth: Float = 0.65f, + /** Draw the central Worthington rebound-jet bead on early impact. */ + val reboundJet: Boolean = true, +) { + fun sanitized() = copy( + propagationSpeed = propagationSpeed.coerceIn(0.05f, 5f), + decayLambda = decayLambda.coerceIn(0.2f, 20f), + maxRadius = maxRadius.coerceIn(0.05f, 1.5f), + secondaryRings = secondaryRings.coerceIn(0, 12), + ringSpacing = ringSpacing.coerceIn(0.005f, 0.3f), + lineWidth = lineWidth.coerceIn(0.001f, 0.05f), + squash = squash.coerceIn(0.1f, 1f), + crownCount = crownCount.coerceIn(3, 32), + troughDepth = troughDepth.coerceIn(0f, 1f), + ) +} + +/** A single raindrop impact. Positions are normalised [0,1] relative to the view. */ +data class OGRippleEvent( + val cx: Float, + val cy: Float, + /** Monotonic millisecond timestamp when this impact occurred (use [withFrameMillis] or similar). */ + val startMs: Long, +) diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGRippleView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGRippleView.kt new file mode 100644 index 0000000..d9cfcd9 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGRippleView.kt @@ -0,0 +1,246 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import com.solidkey.painpoints.ai.toSpecColor +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sin + +/** + * Holds the list of active [OGRippleEvent]s for an [OGRippleView]. + * Spawn new impacts with [spawn]; the view culls expired rings automatically each frame. + */ +class OGRippleState { + private val _events = mutableListOf() + internal val events: List get() = _events + + /** Spawn a new raindrop impact at normalised position ([cx], [cy]). */ + fun spawn(cx: Float, cy: Float, nowMs: Long) { + _events += OGRippleEvent(cx = cx.coerceIn(0f, 1f), cy = cy.coerceIn(0f, 1f), startMs = nowMs) + // Keep at most 32 active impacts (oldest culled on overflow) + if (_events.size > 32) _events.removeAt(0) + } + + internal fun cull(nowMs: Long, spec: OGRippleSpec) { + val maxAgeMs = (spec.maxRadius / spec.propagationSpeed.coerceAtLeast(0.01f)).pow(1f / 0.65f) * 1400f + _events.removeAll { (nowMs - it.startMs) > maxAgeMs } + } +} + +/** Creates and [remember]s an [OGRippleState]. */ +@Composable +fun rememberOGRippleState(): OGRippleState = remember { OGRippleState() } + +/** + * Convenience overload that maintains its own [OGRippleState]. + */ +@Composable +fun OGRippleView( + spec: OGRippleSpec, + modifier: Modifier = Modifier, + playing: Boolean = true, +) { + val state = rememberOGRippleState() + OGRippleView(state = state, spec = spec, modifier = modifier, playing = playing) +} + +/** + * Renders active ripple rings from [state] according to [spec]. Attach [Modifier.ogRippleInput] + * to wire tap-to-spawn; or call [OGRippleState.spawn] manually (e.g. for a rain simulation that + * spawns at random positions). + * + * The view drives its own animation loop — set [playing] to `false` to freeze. + */ +@Composable +fun OGRippleView( + state: OGRippleState, + spec: OGRippleSpec, + modifier: Modifier = Modifier, + playing: Boolean = true, +) { + val s = remember(spec) { spec.sanitized() } + val ringColor = remember(s) { s.color.toSpecColor() ?: Color(0xFF4FC3F7) } + val crestColor = remember(s) { s.crestColor.toSpecColor() ?: Color.White } + + var nowMs by remember { mutableStateOf(0L) } + var tick by remember { mutableIntStateOf(0) } + + LaunchedEffect(s, playing) { + if (!playing) return@LaunchedEffect + while (true) { + withFrameMillis { t -> + nowMs = t + state.cull(t, s) + tick++ + } + } + } + + Canvas(modifier.clipToBounds()) { + @Suppress("UNUSED_EXPRESSION") tick + drawOGRippleFrame(s, state, nowMs, ringColor, crestColor) + } +} + +/** + * Convenience modifier that forwards tap gestures to [state] as ripple impacts. + * Attach this to whatever composable should be tappable (the view itself, or a parent). + */ +fun Modifier.ogRippleInput(state: OGRippleState): Modifier = + this.pointerInput(state) { + var nowMs = 0L + detectTapGestures( + onPress = { offset -> + withFrameMillis { nowMs = it } + state.spawn( + cx = offset.x / size.width, + cy = offset.y / size.height, + nowMs = nowMs, + ) + }, + ) + } + +/** + * Draw one frame of the ripple system at [nowMs]. Shared by [OGRippleView] and any baker. + * + * Each active [OGRippleEvent] contributes: + * 1. A primary ring expanding at r(t) = speed · t^0.65 · minDim. + * 2. [OGRippleSpec.secondaryRings] trailing rings grouped with a Gaussian wave-packet envelope. + * 3. Exponential amplitude decay on all rings: alpha = A₀ · e^(−λ·t). + * 4. **3D meniscus**: dark refraction trough + diffuse water body + razor specular crest. + * 5. Worthington crown — ballistic streak droplets + central rebound jet bead at t < 150ms. + */ +fun DrawScope.drawOGRippleFrame( + spec: OGRippleSpec, + state: OGRippleState, + nowMs: Long, + ringColor: Color, + crestColor: Color, +) { + val w = size.width; val h = size.height + if (w <= 0f || h <= 0f) return + val minDim = min(w, h) + val s = spec.sanitized() + val maxR = s.maxRadius * minDim + val strokeW = s.lineWidth * minDim + val troughCol = s.troughColor.toSpecColor() ?: Color(0xFF051D33) + + for (event in state.events) { + val ageSec = ((nowMs - event.startMs) / 1000f).coerceAtLeast(0f) + + val primaryR = s.propagationSpeed * ageSec.pow(0.65f) * minDim + if (primaryR > maxR) continue + + val amp = exp(-s.decayLambda * ageSec) + if (amp < 0.015f) continue + + val ox = event.cx * w + val oy = event.cy * h + + // 1. Worthington Splash Crown — ballistic streak droplets + rebound bead + if (s.crownDroplets && ageSec < 0.15f) { + val crownProgress = ageSec / 0.15f + val crownAlpha = (1f - crownProgress) * amp + val twoPI = 2f * PI.toFloat() + + for (k in 0 until s.crownCount) { + val angle = k * twoPI / s.crownCount + val speedJitter = 1.0f + 0.5f * OGFxMath.hash01(k, s.seed) + val dist = primaryR * (1.1f + crownProgress * 0.6f * speedJitter) + val arcHeight = 0.045f * minDim * sin(crownProgress * PI.toFloat()) * speedJitter + + val dropX = ox + dist * cos(angle) + val dropY = oy + dist * sin(angle) * s.squash - arcHeight + + // Motion streak along the launch vector + val prevDist = dist * 0.88f + val prevX = ox + prevDist * cos(angle) + val prevY = oy + prevDist * sin(angle) * s.squash - arcHeight * 0.85f + + drawLine( + color = crestColor.copy(alpha = crownAlpha * 0.85f), + start = Offset(prevX, prevY), + end = Offset(dropX, dropY), + strokeWidth = strokeW * 0.9f, + cap = StrokeCap.Round, + ) + } + + // Central Worthington rebound bead + if (s.reboundJet && ageSec < 0.10f) { + val jetT = ageSec / 0.10f + val jetH = 0.035f * minDim * sin(jetT * PI.toFloat()) + drawCircle( + color = crestColor.copy(alpha = (1f - jetT) * amp * 0.9f), + radius = strokeW * 1.3f, + center = Offset(ox, oy - jetH), + ) + } + } + + // 2. Wave Packet Rings with 3D Meniscus + for (ring in 0..s.secondaryRings) { + val r = primaryR - ring * s.ringSpacing * minDim + if (r <= 0f || r > maxR) continue + + // Gaussian wave-packet envelope — natural capillary grouping + val packetWeight = exp(-((ring - 0.5f) * (ring - 0.5f)) / (2f * 1.6f * 1.6f)) + val ringAlpha = amp * packetWeight + + val rx = r; val ry = r * s.squash + + // Layer A: 3D dark refraction trough (water depression just inside the crest) + if (s.troughDepth > 0f) { + val troughR = (r - strokeW * 0.7f).coerceAtLeast(0.5f) + val trX = troughR; val trY = troughR * s.squash + drawOval( + color = troughCol.copy(alpha = ringAlpha * s.troughDepth), + topLeft = Offset(ox - trX, oy - trY), + size = Size(trX * 2f, trY * 2f), + style = Stroke(width = strokeW * 1.25f), + ) + } + + // Layer B: diffuse water body + drawOval( + color = ringColor.copy(alpha = ringAlpha * (if (ring == 0) 0.70f else 0.45f)), + topLeft = Offset(ox - rx, oy - ry), + size = Size(rx * 2f, ry * 2f), + style = Stroke(width = strokeW * (1f - ring * 0.12f).coerceAtLeast(0.4f)), + ) + + // Layer C: razor specular crest highlight on the two leading rings + if (ring <= 1) { + val crestAlpha = ringAlpha * (if (ring == 0) 0.95f else 0.50f) + drawOval( + color = crestColor.copy(alpha = crestAlpha), + topLeft = Offset(ox - rx, oy - ry), + size = Size(rx * 2f, ry * 2f), + style = Stroke(width = strokeW * 0.55f, cap = StrokeCap.Round), + ) + } + } + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGShockwave.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGShockwave.kt new file mode 100644 index 0000000..ddb462d --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGShockwave.kt @@ -0,0 +1,72 @@ +package com.solidkey.painpoints.fx + +import com.solidkey.painpoints.ai.OGAiVector + +/** + * The **codec + presets** for [OGShockwaveSpec] — decode a JSON pack, encode one back, build a model + * prompt, and reach for a ready-made ring ([AIRBURST], [GROUND], [SPLASH], [ELECTRIC]). Mirrors the rest + * of the FX / data layers: lenient JSON, tolerant extraction, no network, no AI SDK. + */ +object OGShockwave { + val json get() = OGAiVector.json + + fun decodeSpec(text: String): OGShockwaveSpec = + json.decodeFromString(OGShockwaveSpec.serializer(), OGAiVector.extractJson(text)) + + fun decodeSpecOrNull(text: String): OGShockwaveSpec? = runCatching { decodeSpec(text) }.getOrNull() + + fun encode(spec: OGShockwaveSpec): String = json.encodeToString(OGShockwaveSpec.serializer(), spec) + + /** Look up a built-in preset by name (case-insensitive), or `null`. */ + fun preset(name: String): OGShockwaveSpec? = presets[name.lowercase()] + + fun shockwavePrompt(instruction: String): String = """ + You output ONLY a JSON object describing an expanding impact shockwave ring for the KMPMedia library. + + It renders using a Sedov blast curve (fast explosive start) with: an epicenter fireball flash at + t<25%, a soft inner compression wake, a sharp Mach-stem leading edge, and a trailing secondary + rebound ring. squash 1.0 is a circle; 0.5 is a 2:1 ground-plane oval. turbulence 0..0.15 adds + harmonic ring flutter. + + Fields (all optional): name, color, maxRadius, ringWidth, squash, alpha, seed, + innerGlow, secondaryRing, coreFlash, turbulence. + + Schema: {"name":"", ...fields...} + Example: {"name":"ground","color":"#FF9800","maxRadius":0.60,"ringWidth":0.038,"squash":0.45,"alpha":0.88,"turbulence":0.055} + + Output the JSON only, with no prose and no markdown fences. + + Task: $instruction + """.trimIndent() + + /** White thunder airburst — full circle expanding from the strike point with core flash. */ + val AIRBURST = OGShockwaveSpec( + name = "airburst", color = "#E0F7FA", maxRadius = 0.50f, ringWidth = 0.028f, + squash = 1.0f, alpha = 0.90f, innerGlow = true, secondaryRing = true, + coreFlash = true, turbulence = 0.02f, + ) + + /** Orange earthquake ground roll — wide 2:1 oval, high turbulence, heavy flash. */ + val GROUND = OGShockwaveSpec( + name = "ground", color = "#FF9800", maxRadius = 0.60f, ringWidth = 0.038f, + squash = 0.45f, alpha = 0.88f, innerGlow = true, secondaryRing = true, + coreFlash = true, turbulence = 0.055f, + ) + + /** Light-blue flood splash — water ring with gentle turbulence, no core flash. */ + val SPLASH = OGShockwaveSpec( + name = "splash", color = "#4FC3F7", maxRadius = 0.52f, ringWidth = 0.025f, + squash = 0.55f, alpha = 0.80f, innerGlow = true, secondaryRing = true, + coreFlash = false, turbulence = 0.015f, + ) + + /** Pale-gold electric burst — tight circle, strong flash, wild turbulence. */ + val ELECTRIC = OGShockwaveSpec( + name = "electric", color = "#FFF59D", maxRadius = 0.45f, ringWidth = 0.022f, + squash = 1.0f, alpha = 0.95f, innerGlow = true, secondaryRing = true, + coreFlash = true, turbulence = 0.075f, + ) + + /** All built-in presets, keyed by lowercase name. */ + val presets: Map = listOf(AIRBURST, GROUND, SPLASH, ELECTRIC).associateBy { it.name!! } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGShockwaveSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGShockwaveSpec.kt new file mode 100644 index 0000000..fff268c --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGShockwaveSpec.kt @@ -0,0 +1,144 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import com.solidkey.painpoints.ai.toSpecColor +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sin + +/** + * **Shockwave** — an expanding impact ring, described as plain data. Part of the + * `com.solidkey.painpoints.fx` family. Decode + presets on [OGShockwave]; draw a frame with + * [drawOGShockwaveFrame]. + * + * The ring expands from a centre point as a caller-driven `progress` goes `0 → 1` using a Sedov + * blast curve ($r \propto p^{0.52}$) — explosively fast at detonation, decelerating as mass is + * pushed. Visual layers: an initial core flash, a soft inner compression wake, a razor Mach-stem + * leading edge, and a trailing secondary rebound ring. Optional harmonic turbulence distorts the + * ring boundary with deterministic sine harmonics. No shader, no per-pixel work — pure Canvas. + */ +@kotlinx.serialization.Serializable +data class OGShockwaveSpec( + val name: String? = null, + /** Ring colour — any SVG colour string. */ + val color: String = "#FFFFFF", + /** Ring radius at `progress = 1`, as a fraction of `min(width, height)`. */ + val maxRadius: Float = 0.45f, + /** Stroke width at `progress = 0`, as a fraction of `min(width, height)`. */ + val ringWidth: Float = 0.025f, + /** Vertical squash: `1.0` = circle, `0.5` = 2:1 ground-plane oval. */ + val squash: Float = 1.0f, + /** Peak alpha at `progress = 0`; fades toward `0` at `progress = 1`. */ + val alpha: Float = 0.85f, + /** Seed for deterministic harmonic turbulence phase. */ + val seed: Int = 0, + /** Soft diffused inner compression wake behind the leading edge. */ + val innerGlow: Boolean = true, + /** Trailing secondary harmonic rebound ring. */ + val secondaryRing: Boolean = true, + /** Epicenter detonation fireball flash at `progress < 25%`. */ + val coreFlash: Boolean = true, + /** Edge flutter (`0` = smooth circle, `0.05` = fiery distorted blast). */ + val turbulence: Float = 0.035f, +) { + /** Clamp every field into its sane range. */ + fun sanitized(): OGShockwaveSpec = copy( + maxRadius = maxRadius.coerceIn(0.01f, 2f), + ringWidth = ringWidth.coerceIn(0.001f, 0.5f), + squash = squash.coerceIn(0.1f, 1f), + alpha = alpha.coerceIn(0f, 1f), + turbulence = turbulence.coerceIn(0f, 0.15f), + ) +} + +/** + * Draw one frame of a [spec]'s shockwave into the current [DrawScope], centred at the normalized + * `(cx, cy)`, at [progress] `0..1`. Nothing is drawn outside that range. + * + * Rendering layers (back to front): + * 1. **Core flash** — brilliant epicenter fireball at progress < 0.25 that rapidly collapses. + * 2. **Secondary rebound ring** — trailing harmonic wave behind the main front. + * 3. **Inner compression wake** — soft wide glow body just inside the crest. + * 4. **Mach stem** — razor-thin bright leading edge (optionally turbulence-distorted). + */ +fun DrawScope.drawOGShockwaveFrame(spec: OGShockwaveSpec, cx: Float, cy: Float, progress: Float) { + if (progress <= 0f || progress >= 1f) return + val s = spec.sanitized() + val w = size.width; val h = size.height + if (w <= 0f || h <= 0f) return + val minSide = min(w, h) + val color = s.color.toSpecColor() ?: Color.White + val center = Offset(cx * w, cy * h) + + // Sedov blast expansion curve — explosive fast start, deceleration over time + val blastP = progress.pow(0.52f) + val baseAlpha = (s.alpha * (1f - progress.pow(1.3f))).coerceIn(0f, 1f) + + // 1. Core Detonation Flash (epicenter fireball that collapses) + if (s.coreFlash && progress < 0.25f) { + val flashT = progress / 0.25f + val flashAlpha = ((1f - flashT).pow(2f) * s.alpha).coerceIn(0f, 1f) + val flashR = minSide * 0.10f * (1f + flashT * 0.8f) + drawOval( + color = Color.White.copy(alpha = flashAlpha * 0.85f), + topLeft = Offset(center.x - flashR, center.y - flashR * s.squash), + size = Size(flashR * 2f, flashR * 2f * s.squash), + ) + } + + // Helper: draw a single shock ring (smooth oval or turbulence-distorted path) + fun drawRing(rFrac: Float, strokeFrac: Float, ringAlpha: Float, crestHighlight: Boolean) { + if (ringAlpha <= 0.01f || rFrac <= 0f) return + val rx = rFrac * s.maxRadius * minSide + val ry = rx * s.squash + val stroke = (strokeFrac * minSide).coerceAtLeast(1f) + val ringColor = if (crestHighlight) Color.White.copy(alpha = ringAlpha) else color.copy(alpha = ringAlpha) + + if (s.turbulence <= 0.002f) { + drawOval( + color = ringColor, + topLeft = Offset(center.x - rx, center.y - ry), + size = Size(rx * 2f, ry * 2f), + style = Stroke(width = stroke), + ) + } else { + val path = Path() + val steps = 48 + val turbAmp = s.turbulence * (1f - progress) + for (i in 0 until steps) { + val theta = i * (2f * PI.toFloat() / steps) + val flutter = 1f + turbAmp * sin(7f * theta + s.seed.toFloat()) * cos(3f * theta) + val px = center.x + cos(theta) * rx * flutter + val py = center.y + sin(theta) * ry * flutter + if (i == 0) path.moveTo(px, py) else path.lineTo(px, py) + } + path.close() + drawPath(path, color = ringColor, style = Stroke(width = stroke)) + } + } + + // 2. Trailing Secondary Harmonic Wave (behind main front) + if (s.secondaryRing && blastP > 0.18f) { + val secP = ((blastP - 0.18f) / 0.82f).coerceIn(0f, 1f) + val secAlpha = baseAlpha * 0.45f * (1f - secP) + val secStroke = s.ringWidth * 0.65f * (1f - 0.4f * secP) + drawRing(secP * 0.82f, secStroke, secAlpha, crestHighlight = false) + } + + // 3. Inner Compression Wake / Glow (soft wide body) + if (s.innerGlow) { + val glowStroke = s.ringWidth * 2.4f * (1f - 0.5f * blastP) + drawRing(blastP * 0.96f, glowStroke, baseAlpha * 0.32f, crestHighlight = false) + } + + // 4. Razor Mach Stem (sharp bright leading edge) + val crestStroke = (s.ringWidth * 0.70f * (1f - 0.4f * blastP)).coerceAtLeast(1f / minSide) + drawRing(blastP, crestStroke, baseAlpha * 0.95f, crestHighlight = true) +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGShockwaveView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGShockwaveView.kt new file mode 100644 index 0000000..45da523 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGShockwaveView.kt @@ -0,0 +1,25 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds + +/** + * Renders an expanding impact shockwave ring ([spec]) centered at ([cx], [cy]) as [progress] + * runs `0..1`. The view is transparent everywhere it isn't drawn. + */ +@Composable +fun OGShockwaveView( + spec: OGShockwaveSpec, + modifier: Modifier = Modifier, + cx: Float = 0.5f, + cy: Float = 0.5f, + progress: Float = 1f, +) { + val s = remember(spec) { spec.sanitized() } + Canvas(modifier.clipToBounds()) { + drawOGShockwaveFrame(s, cx, cy, progress) + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGSlashes.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGSlashes.kt index 181a59b..259d564 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGSlashes.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGSlashes.kt @@ -67,7 +67,7 @@ object OGSlashes { name = "water", width = 0.12f, taper = 0.9f, peak = 0.42f, smoothing = 1f, samples = 80, colorStart = "#4FC3F7", colorEnd = "#01579B", glow = 0.8f, glowColor = "#B3E5FC", - edge = "wave", edgeAmp = 0.022f, edgeSpeed = 1.2f, edgeDetail = 4, + edge = "wave", edgeAmp = 0.032f, edgeSpeed = 1.2f, edgeDetail = 5, breatheAmp = 0.14f, breatheSpeed = 0.9f, revealMs = 420f, holdMs = 900f, fadeMs = 380f, loop = true, seed = 0, ) diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGWave.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGWave.kt new file mode 100644 index 0000000..5895839 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGWave.kt @@ -0,0 +1,73 @@ +package com.solidkey.painpoints.fx + +import com.solidkey.painpoints.ai.OGAiVector + +/** + * The **codec + presets** for [OGWaveSpec] — decode a JSON pack, encode one back, build a model + * prompt, and reach for a ready-made surface ([CALM], [OCEAN], [FLOOD], [LAVA]). Mirrors the rest of + * the FX / data layers: lenient JSON, tolerant extraction, no network, no AI SDK. + */ +object OGWave { + val json get() = OGAiVector.json + + fun decodeSpec(text: String): OGWaveSpec = + json.decodeFromString(OGWaveSpec.serializer(), OGAiVector.extractJson(text)) + + fun decodeSpecOrNull(text: String): OGWaveSpec? = runCatching { decodeSpec(text) }.getOrNull() + + fun encode(spec: OGWaveSpec): String = json.encodeToString(OGWaveSpec.serializer(), spec) + + /** Look up a built-in preset by name (case-insensitive), or `null`. */ + fun preset(name: String): OGWaveSpec? = presets[name.lowercase()] + + fun wavePrompt(instruction: String): String = """ + You output ONLY a JSON object describing a scrolling water surface for the KMPMedia library. + + It renders as overlapping wave bands (travelling Gerstner waves — sharp crests, broad troughs — + + fractal noise) with an optional white foam strip at the crests. Coordinates: surfaceY is 0..1 + of the view (0 = top); amplitude is a fraction of the view height; speed is a fraction of the + view per second (+ = right); steepness 0..1 controls crest sharpness (0.65 = classic ocean); + opacity is 0..1; a slow dark-red/orange pair reads as lava. + + Fields (all optional): name, deepColor, shallowColor, layers, amplitude, frequency, speed, + turbulence, foam, foamThickness, opacity, surfaceY, octaves, steepness, seed. + + Schema: {"name":"", ...fields...} + Example: {"name":"ocean","deepColor":"#0D47A1","shallowColor":"#29B6F6","layers":4,"amplitude":0.1,"frequency":3,"speed":0.3,"turbulence":0.55,"foam":true} + + Output the JSON only, with no prose and no markdown fences. + + Task: $instruction + """.trimIndent() + + /** A quiet lake — two slow, low bands with no foam. */ + val CALM = OGWaveSpec( + name = "calm", deepColor = "#1565C0", shallowColor = "#4FC3F7", layers = 2, + amplitude = 0.05f, frequency = 2f, speed = 0.15f, turbulence = 0.3f, + foam = false, surfaceY = 0.55f, seed = 7, + ) + + /** Choppy open ocean — four bands, taller and faster, with foam crests. */ + val OCEAN = OGWaveSpec( + name = "ocean", deepColor = "#0D47A1", shallowColor = "#29B6F6", layers = 4, + amplitude = 0.10f, frequency = 3f, speed = 0.30f, turbulence = 0.55f, + foam = true, surfaceY = 0.45f, seed = 17, + ) + + /** Rising flood water — a high surface racing sideways, turbulent, foaming. */ + val FLOOD = OGWaveSpec( + name = "flood", deepColor = "#1A237E", shallowColor = "#42A5F5", layers = 3, + amplitude = 0.07f, frequency = 2.5f, speed = 0.55f, turbulence = 0.65f, + foam = true, surfaceY = 0.35f, seed = 29, + ) + + /** Slow molten lava — low, viscous red-orange swells, very turbulent, no foam. */ + val LAVA = OGWaveSpec( + name = "lava", deepColor = "#B71C1C", shallowColor = "#FF8F00", layers = 2, + amplitude = 0.06f, frequency = 1.5f, speed = 0.08f, turbulence = 0.80f, + foam = false, surfaceY = 0.60f, seed = 41, + ) + + /** All built-in presets, keyed by lowercase name. */ + val presets: Map = listOf(CALM, OCEAN, FLOOD, LAVA).associateBy { it.name!! } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGWaveSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGWaveSpec.kt new file mode 100644 index 0000000..d8d6ce4 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGWaveSpec.kt @@ -0,0 +1,179 @@ +package com.solidkey.painpoints.fx + +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * **Water surface / waves** — overlapping scrolling wave bands, described as plain data. Part of the + * `com.solidkey.painpoints.fx` family. Decode + presets on [OGWave]; draw with [OGWaveView]. + * + * **How it stays 60fps (the perf gate).** No per-pixel work and no shaders: each layer is one closed + * path (a sampled crest line down to the bottom edge) filled with a single vertical gradient, plus an + * optional foam stroke along the crest. The crest profile is a sum of **Gerstner (trochoidal) waves** + * — the closed-form gravity-wave solution, giving the sharp-crest/broad-trough water silhouette — with + * fractal [OGFxNoise] chop on top, sampled at a handful of points per band — pure `kotlin.math`, + * deterministic, frame-identical on Android & iOS. + * + * **Coordinates.** Everything is normalized `0..1` over the view; [amplitude] is a fraction of the + * view height; [speed] is a fraction of the view per second. + */ +@kotlinx.serialization.Serializable +data class OGWaveSpec( + val name: String? = null, + /** Deep water colour — visible in the troughs and where waves are opaque. Any SVG colour string. */ + val deepColor: String = "#1565C0", + /** Shallow/crest colour — the lighter tone at wave tops and foam edges. Any SVG colour string. */ + val shallowColor: String = "#4FC3F7", + /** Number of overlapping wave bands. 2 = calm; 4 = choppy ocean. Clamped 1..8. */ + val layers: Int = 3, + /** Wave amplitude as a fraction of the view height (how tall the waves are). */ + val amplitude: Float = 0.08f, + /** Base wave frequency — how many wave crests fit across the view. */ + val frequency: Float = 2.5f, + /** How fast the waves scroll horizontally (fraction of the view per second, + = right). */ + val speed: Float = 0.25f, + /** Organic waviness from fractal noise added on top of the base sine (fraction of amplitude). */ + val turbulence: Float = 0.45f, + /** Whether to draw a white foam strip at wave crests. */ + val foam: Boolean = true, + /** Foam strip thickness as a fraction of the wave amplitude. */ + val foamThickness: Float = 0.15f, + /** Overall opacity ceiling for the wave fills (0..1). */ + val opacity: Float = 0.85f, + /** Y position of the wave surface, 0..1 of the view (0 = top, 1 = bottom; 0.5 = mid-screen). */ + val surfaceY: Float = 0.5f, + /** Fractal-noise octaves for the wave turbulence. Clamped 1..6. */ + val octaves: Int = 3, + /** + * Wave **peak sharpness** `0..1` — the Gerstner steepness. `0` = rounded (still more watery than a + * sine); `~0.65` = classic sharp-crest/broad-trough ocean; near `1` = very peaked chop (clamped to + * `0.95` so crests never self-intersect). [turbulence] adds a little extra steepness on top. + */ + val steepness: Float = 0.65f, + /** Seed for deterministic wave layout. */ + val seed: Int = 0, +) { + /** Clamp every field into its sane range (lenient model/designer input never breaks a frame). */ + fun sanitized(): OGWaveSpec = copy( + layers = layers.coerceIn(1, 8), + amplitude = amplitude.coerceIn(0.005f, 0.5f), + frequency = frequency.coerceAtLeast(0.1f), + speed = speed, + turbulence = turbulence.coerceIn(0f, 2f), + foamThickness = foamThickness.coerceIn(0f, 1f), + opacity = opacity.coerceIn(0f, 1f), + surfaceY = surfaceY.coerceIn(0f, 1f), + octaves = octaves.coerceIn(1, 6), + steepness = steepness.coerceIn(0f, 0.95f), + ) +} + +/** One resolved wave band at a given time — the crest y-profile as a list of normalized x,y points + * (enough samples for a smooth curve), the [fillAlpha], and whether to draw [foam]. */ +data class OGWaveBand(val crests: List, val fillAlpha: Float, val foam: Boolean) + +/** A normalized `0..1` point on a wave crest. */ +data class OGWavePoint(val x: Float, val y: Float) + +/** Horizontal samples per band — enough for a smooth crest at any phone width. */ +private const val WAVE_SAMPLES = 32 + +/** Gerstner wave trains summed per band — enough for sharp crests without visible repetition. */ +private const val WAVE_TRAINS = 4 + +/** Scaled gravitational constant for the deep-water dispersion relation `ω = √(g·k)`. */ +private const val WAVE_G = 9.8f + +/** Relative train amplitudes: one dominant swell + progressively smaller cross-chop (sums to 1). */ +private val WAVE_WEIGHTS = floatArrayOf(1f, 0.55f, 0.32f, 0.20f) +private val WAVE_WEIGHT_SUM = WAVE_WEIGHTS.sum() + +/** + * Resolve [OGWaveSpec.layers] wave bands at [timeSec] — pure + deterministic from [OGWaveSpec.seed], + * so the same `(spec, time)` always yields the same bands (and both platforms match). + * + * Each band's crest profile is a sum of **Gerstner (trochoidal) waves** — the closed-form solution to + * the irrotational free-surface gravity-wave equation. Each train `i` displaces a surface point both + * vertically *and horizontally*: + * ``` + * x(u,t) = u − Σ (Qᵢ/kᵢ)·sin(kᵢu − ωᵢt + φᵢ) y(u,t) = Σ Aᵢ·cos(kᵢu − ωᵢt + φᵢ) + * ``` + * with the deep-water dispersion relation `ωᵢ = √(g·kᵢ)` so long waves travel faster than short ones. + * The horizontal displacement bunches samples at crests, producing the characteristic **sharp-crest / + * broad-trough** water silhouette a plain sine can never make. We sample at fixed screen `x` and invert + * the parametrization with one fixed-point step, so crest endpoints stay pinned at `x = 0` and `x = 1`. + * Total steepness `ΣQᵢ` is driven by [OGWaveSpec.steepness] (plus a little [OGWaveSpec.turbulence]) + * and capped at `0.85` so crests never self-intersect. Fractal [OGFxNoise] chop still rides on top. + * + * Back layers sit lower, shorter and more transparent; the view scales the normalized crest points + * into pixels. + */ +fun waveBands(spec: OGWaveSpec, timeSec: Float): List { + val n = spec.layers.coerceIn(1, 8) + val oct = spec.octaves.coerceIn(1, 6) + val twoPi = 2f * PI.toFloat() + // Total crest steepness ΣQᵢ: the spec's dial plus a touch of extra bite from turbulence, + // capped below 1 so the trochoid never self-intersects (loops over itself). + val totalSteepness = (spec.steepness + 0.25f * spec.turbulence).coerceIn(0f, 0.85f) + + return List(n) { k -> + val freqMult = 1f + k * 0.18f + val speedMult = 1f - k * 0.12f + val ampMult = 1f - k * 0.15f // back layers slightly shorter + + // Depth offset: back layers sit lower (deeper water behind the front) + val depthOffset = k * spec.amplitude * 0.6f + + // ── Build this band's Gerstner wave trains (deterministic from the seed) ────────────────── + val amp = FloatArray(WAVE_TRAINS) // Aᵢ (normalized view-height units) + val wavek = FloatArray(WAVE_TRAINS) // kᵢ = |k⃗ᵢ| (rad per normalized x unit) + val dir = FloatArray(WAVE_TRAINS) // k⃗ᵢₓ/|k⃗ᵢ| — x-projection of the train's direction + val omega = FloatArray(WAVE_TRAINS) // ωᵢ = √(g·kᵢ) · speed + val phase = FloatArray(WAVE_TRAINS) // φᵢ — hashed random phase + var qSum = 0f // Σ Aᵢ·kᵢ before normalization + for (i in 0 until WAVE_TRAINS) { + // Spatial frequency scatter 0.8..1.3 of the base, so trains beat against each other. + val fJit = 0.8f + 0.5f * OGFxMath.hash01(i * 13 + k * 7 + 3, spec.seed) + wavek[i] = spec.frequency * freqMult * fJit * twoPi + // Direction within ±35° of horizontal: only the x-projection matters for a profile cut. + val angDeg = 35f * OGFxMath.hashSigned(i * 17 + k * 5 + 1, spec.seed) + dir[i] = cos(angDeg * PI.toFloat() / 180f) + amp[i] = spec.amplitude * ampMult * WAVE_WEIGHTS[i] / WAVE_WEIGHT_SUM + // Deep-water dispersion ω=√(g·k); spec.speed scales propagation (0 freezes the surface). + omega[i] = sqrt(WAVE_G * wavek[i]) * spec.speed * speedMult + phase[i] = OGFxMath.hash01(i * 29 + k * 7 + 3, spec.seed) * twoPi + qSum += amp[i] * wavek[i] * dir[i] + } + // Normalize per-train steepness Qᵢ so the band's total hits exactly totalSteepness. + val qScale = if (qSum > 1e-6f) totalSteepness / qSum else 0f + + val crests = List(WAVE_SAMPLES + 1) { j -> + val nx = j.toFloat() / WAVE_SAMPLES // normalized screen x 0..1 + // Invert x(u)=nx with one fixed-point step: u ≈ nx + Σ (Qᵢ/kᵢ)·sin(θᵢ(nx)). + var du = 0f + for (i in 0 until WAVE_TRAINS) { + val theta = wavek[i] * dir[i] * nx - omega[i] * timeSec + phase[i] + du += amp[i] * qScale * dir[i] * sin(theta) + } + val u = nx + du + // Height at the recovered parameter u: crests bunch, troughs stretch → trochoidal profile. + var gy = 0f + for (i in 0 until WAVE_TRAINS) { + gy += amp[i] * cos(wavek[i] * dir[i] * u - omega[i] * timeSec + phase[i]) + } + // Organic fbm chop on top (foam-scale detail); Gerstner is the base. + val noiseY = spec.turbulence * spec.amplitude * ampMult * + OGFxNoise.fbm( + nx * spec.frequency * freqMult + timeSec * spec.speed * speedMult * 0.7f, + timeSec * 0.3f + k * 1.9f, oct, spec.seed + k * 31, + ) + // gy > 0 is a crest → smaller screen y (upward). + OGWavePoint(nx, spec.surfaceY + depthOffset - gy + noiseY) + } + // Front layers are more opaque; back layers more transparent + val fillAlpha = (spec.opacity * (1f - k * 0.22f)).coerceIn(0f, 1f) + OGWaveBand(crests, fillAlpha, spec.foam) + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGWaveView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGWaveView.kt new file mode 100644 index 0000000..abf601e --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/fx/OGWaveView.kt @@ -0,0 +1,230 @@ +package com.solidkey.painpoints.fx + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import com.solidkey.painpoints.ai.toSpecColor +import kotlin.math.PI +import kotlin.math.sin + +/** + * Draw and run a [spec]'s **water surface**, filling this composable's bounds with scrolling wave + * bands (and optional foam crests). Set [playing] to `false` to freeze it. + * + * 60fps + cross-platform: each band is one closed path filled with a single vertical gradient — no + * per-pixel work, no shaders (see [OGWaveSpec]). Overlay it in a `Box` over your scene; everything + * above the crest line stays transparent. For a rougher sea, raise [OGWaveSpec.layers] or + * [OGWaveSpec.turbulence]. + */ +@Composable +fun OGWaveView( + spec: OGWaveSpec, + modifier: Modifier = Modifier, + playing: Boolean = true, +) { + val s = remember(spec) { spec.sanitized() } + val deep = remember(s) { s.deepColor.toSpecColor() ?: Color(0xFF1565C0) } + val shallow = remember(s) { s.shallowColor.toSpecColor() ?: Color(0xFF4FC3F7) } + + var elapsed by remember(s) { mutableStateOf(0f) } + LaunchedEffect(s, playing) { + if (!playing) return@LaunchedEffect + val start = withFrameMillis { it } + while (true) withFrameMillis { elapsed = (it - start).toFloat() } + } + + Canvas(modifier.clipToBounds()) { + drawOGWaveFrame(s, elapsed / 1000f, deep, shallow) + } +} + +/** + * Draw one frame of a [spec]'s waves at [timeSec] with its resolved colours, into the current + * [DrawScope]. The per-frame body shared by the live [OGWaveView] and any off-screen baker — one + * code path, so an export matches the preview. Back bands are drawn first; each band is a closed + * crest-to-bottom path with a vertical shallow→deep gradient, then an optional round-capped white + * foam stroke along the crest. Clip to the bounds first. + */ +internal fun DrawScope.drawOGWaveFrame( + spec: OGWaveSpec, + timeSec: Float, + deepColor: Color, + shallowColor: Color, +) { + val w = size.width; val h = size.height + if (w <= 0f || h <= 0f) return + + val bands = waveBands(spec, timeSec) + // Back layers first (last in the list), front band last so it reads on top. + for (i in bands.indices.reversed()) { + val band = bands[i] + val crests = band.crests + if (crests.size < 2) continue + + val path = Path().apply { + moveTo(0f, crests[0].y * h) + for (c in crests) lineTo(c.x * w, c.y * h) + lineTo(w, h) + lineTo(0f, h) + close() + } + val minCrestY = crests.minOf { it.y } + val fill = Brush.linearGradient( + colorStops = arrayOf( + 0f to shallowColor.copy(alpha = band.fillAlpha), + 0.4f to deepColor.copy(alpha = band.fillAlpha * 0.8f), + 1f to deepColor.copy(alpha = band.fillAlpha * 0.3f), + ), + start = Offset(w * 0.5f, minCrestY * h - spec.amplitude * h), + end = Offset(w * 0.5f, h), + ) + drawPath(path, brush = fill) + + if (band.foam) { + // Draw multi-layered, broken cinematic foam instead of a single "worm" line + val rngF = kotlin.random.Random(spec.seed + i) + val foamColor = Color.White.copy(alpha = band.fillAlpha * 0.75f) + val baseW = (spec.amplitude * spec.foamThickness * h).coerceAtLeast(1f) + + for (layer in 0..2) { + val foamPath = Path() + var isDown = false + for (j in 0 until crests.size) { + val cx = crests[j].x * w + val cy = crests[j].y * h + if (layer == 0) 0f else (rngF.nextFloat() - 0.5f) * baseW * 1.5f + if (j == 0) { + foamPath.moveTo(cx, cy) + isDown = true + } else { + // Randomly break the path for a dashed/scattered foam look + if (rngF.nextFloat() > 0.15f) { + if (!isDown) foamPath.moveTo(cx, cy) + else foamPath.lineTo(cx, cy) + isDown = true + } else { + isDown = false + } + } + } + val fw = if (layer == 0) baseW else baseW * (0.4f + 0.6f * rngF.nextFloat()) + drawPath( + foamPath, + brush = SolidColor(if (layer == 0) foamColor.copy(alpha = foamColor.alpha * 0.6f) else foamColor), + style = Stroke(width = fw, cap = StrokeCap.Round) + ) + } + } + } +} + +/** + * Draw a single traveling wave-crest band that sweeps top→bottom of the view. + * [travelPos] is the band's normalised travel position (0 = at the view top, 1 = at the view + * bottom, values slightly above 1 let it exit below). [bandHeightFrac] controls band thickness + * as a fraction of the view height. [phase] (radians) offsets the undulation so staggered bands + * look distinct. Colours come from [OGWaveSpec.shallowColor] (crest) and [OGWaveSpec.deepColor] + * (body). Foam is drawn when [OGWaveSpec.foam] is true. + */ +fun DrawScope.drawOGWaveFrontFrame( + spec: OGWaveSpec, + travelPos: Float, + bandHeightFrac: Float = 0.18f, + phase: Float = 0f, +) { + val w = size.width; val h = size.height + if (w <= 0f || h <= 0f) return + val s = spec.sanitized() + + // Smoothstep fade-in 0→0.15 and fade-out 0.85→1.15 + val uIn = (travelPos / 0.15f).coerceIn(0f, 1f) + val fadeIn = uIn * uIn * (3f - 2f * uIn) + val uOut = ((travelPos - 0.85f) / 0.30f).coerceIn(0f, 1f) + val fadeOut = 1f - uOut * uOut * (3f - 2f * uOut) + val bandAlpha = fadeIn * fadeOut + if (bandAlpha <= 0f) return + + val crestColor = s.shallowColor.toSpecColor() ?: Color.White + val bodyColor = s.deepColor.toSpecColor() ?: Color(0xFF003A6EL) + + val steps = 40 + val twoPI = 2f * PI.toFloat() + val yBase = (travelPos - 0.35f) * h * 1.4f + val bandH = bandHeightFrac * h + + // Crest stroke + val crestPath = Path() + for (si in 0..steps) { + val xFrac = si.toFloat() / steps + val x = xFrac * w + val y = yBase + sin(xFrac * s.frequency * twoPI + phase) * s.amplitude * h + if (si == 0) crestPath.moveTo(x, y) else crestPath.lineTo(x, y) + } + drawPath(crestPath, crestColor.copy(alpha = bandAlpha * 0.95f), + style = Stroke(width = h * 0.024f, cap = StrokeCap.Round)) + + // Filled body + val bodyPath = Path() + for (si in 0..steps) { + val xFrac = si.toFloat() / steps + val x = xFrac * w + val y = yBase + sin(xFrac * s.frequency * twoPI + phase) * s.amplitude * h + if (si == 0) bodyPath.moveTo(x, y) else bodyPath.lineTo(x, y) + } + bodyPath.lineTo(w, yBase + bandH) + for (si in steps downTo 0) { + val xFrac = si.toFloat() / steps + val x = xFrac * w + val y = yBase + bandH + sin(xFrac * s.frequency * twoPI + phase + 1.1f) * s.amplitude * h * 0.6f + bodyPath.lineTo(x, y) + } + bodyPath.close() + drawPath(bodyPath, bodyColor.copy(alpha = bandAlpha * 0.82f)) + + // Foam flecks + if (s.foam) { + val rngF = kotlin.random.Random(s.seed + phase.toInt()) + val foamW = h * 0.015f + for (layer in 0..2) { + val fPath = Path() + var isDown = false + for (si in 0..steps) { + val xFrac = si.toFloat() / steps + val x = xFrac * w + val y = yBase + bandH * 0.22f + sin(xFrac * s.frequency * twoPI + phase + 0.5f) * s.amplitude * h * 0.7f + if (layer == 0) 0f else (rngF.nextFloat() - 0.5f) * foamW * 4.5f + if (si == 0) { + fPath.moveTo(x, y) + isDown = true + } else { + if (rngF.nextFloat() > 0.15f) { + if (!isDown) fPath.moveTo(x, y) + else fPath.lineTo(x, y) + isDown = true + } else { + isDown = false + } + } + } + val fw = if (layer == 0) foamW else foamW * (0.5f + 0.5f * rngF.nextFloat()) + drawPath( + fPath, + Color.White.copy(alpha = bandAlpha * (if(layer == 0) 0.45f else 0.75f)), + style = Stroke(width = fw, cap = StrokeCap.Round) + ) + } + } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/gesture/OGButtonModifier.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/gesture/OGButtonModifier.kt index 53a12c6..d2845f1 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/gesture/OGButtonModifier.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/gesture/OGButtonModifier.kt @@ -45,6 +45,12 @@ sealed interface OGPressEffect { /** Fade to [alpha] while pressed (e.g. `0.6`), returning to opaque on release. */ data class Dim(val alpha: Float = 0.6f) : OGPressEffect + /** + * Tactile 3D button press: top surface translates straight down along Y by [depth] (compressing + * the 3D bottom extrusion lip), accompanied by a subtle [scale] settle. + */ + data class Tactile(val depth: Dp = 4.dp, val scale: Float = 0.985f) : OGPressEffect + /** * The flat/pop-art "hard shadow push-in": the modifier **draws its own** hard-offset silhouette * shadow (no blur) behind the content at rest, and on press the content slides onto it by [offset] @@ -59,15 +65,21 @@ sealed interface OGPressEffect { } /** The content transform at press depth [progress] (`0` = rest, `1` = fully pressed). Pure + testable. */ -internal data class OGButtonTransform(val scale: Float, val alpha: Float, val translateFraction: Float) +internal data class OGButtonTransform( + val scale: Float, + val alpha: Float, + val translateFraction: Float, + val translateYFraction: Float = 0f, +) internal fun OGPressEffect.transformAt(progress: Float): OGButtonTransform { val p = progress.coerceIn(0f, 1f) return when (this) { - is OGPressEffect.Scale -> OGButtonTransform(1f + (scale - 1f) * p, 1f, 0f) - is OGPressEffect.Dim -> OGButtonTransform(1f, 1f + (alpha - 1f) * p, 0f) - is OGPressEffect.Brutalist -> OGButtonTransform(1f, 1f, p) - OGPressEffect.None -> OGButtonTransform(1f, 1f, 0f) + is OGPressEffect.Scale -> OGButtonTransform(1f + (scale - 1f) * p, 1f, 0f, 0f) + is OGPressEffect.Dim -> OGButtonTransform(1f, 1f + (alpha - 1f) * p, 0f, 0f) + is OGPressEffect.Tactile -> OGButtonTransform(1f + (scale - 1f) * p, 1f, 0f, p) + is OGPressEffect.Brutalist -> OGButtonTransform(1f, 1f, p, 0f) + OGPressEffect.None -> OGButtonTransform(1f, 1f, 0f, 0f) } } @@ -175,6 +187,8 @@ fun Modifier.ogButton( val off = pressEffect.offset.toPx() * t.translateFraction translationX = off translationY = off + } else if (pressEffect is OGPressEffect.Tactile) { + translationY = pressEffect.depth.toPx() * t.translateYFraction } } diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/glass/OGGlass.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/glass/OGGlass.kt new file mode 100644 index 0000000..0e1dece --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/glass/OGGlass.kt @@ -0,0 +1,58 @@ +package com.solidkey.painpoints.glass + +import com.solidkey.painpoints.ai.OGAiVector + +/** + * The **codec + presets** for [OGGlassSpec] — decode a JSON pack, encode one back, build a model prompt, + * and reach for a ready-made lens ([MAGNIFIER], [READER], [MINIFIER], [AMBER], [ROSE]). Mirrors the rest + * of the library's data layers: lenient JSON, tolerant extraction, no network, no AI SDK. + */ +object OGGlass { + val json get() = OGAiVector.json + + fun decodeSpec(text: String): OGGlassSpec = + json.decodeFromString(OGGlassSpec.serializer(), OGAiVector.extractJson(text)) + + fun decodeSpecOrNull(text: String): OGGlassSpec? = runCatching { decodeSpec(text) }.getOrNull() + + fun encode(spec: OGGlassSpec): String = json.encodeToString(OGGlassSpec.serializer(), spec) + + /** Look up a built-in preset by name (case-insensitive), or `null`. */ + fun preset(name: String): OGGlassSpec? = presets[name.lowercase()] + + fun glassPrompt(instruction: String): String = """ + You output ONLY a JSON object describing a round glass lens overlay for the KMPMedia library. + + It renders as a circle laid over content: zoom > 1 magnifies what's underneath, zoom < 1 minifies, + tint colours the glass. radius is a fraction of the shorter side of the view. tintStrength, + rimAlpha and highlight are 0..1; rimWidth is a fraction of the radius. + + Fields (all optional): name, zoom, radius, tint, tintStrength, rimColor, rimWidth, rimAlpha, highlight. + + Schema: {"name":"", ...fields...} + Example: {"name":"magnifier","zoom":2.2,"radius":0.22,"tint":"#00000000","highlight":0.3} + + Output the JSON only, with no prose and no markdown fences. + + Task: $instruction + """.trimIndent() + + /** A clear reading magnifier — the everyday "glass over the text" lens. */ + val MAGNIFIER = OGGlassSpec(name = "magnifier", zoom = 2.2f, radius = 0.22f, highlight = 0.32f) + + /** A gentler, wider low-vision reader — less zoom but a bigger window so more is legible at once. */ + val READER = OGGlassSpec(name = "reader", zoom = 1.6f, radius = 0.32f, tint = "#FFFDF4", tintStrength = 0.12f, highlight = 0.25f) + + /** A reducing glass — shrinks what's underneath to fit more of a big board/photo into the circle. */ + val MINIFIER = OGGlassSpec(name = "minifier", zoom = 0.55f, radius = 0.26f, highlight = 0.28f) + + /** Warm amber glass that also magnifies — a high-contrast, glare-cutting reading tint. */ + val AMBER = OGGlassSpec(name = "amber", zoom = 1.8f, radius = 0.24f, tint = "#FFB300", tintStrength = 0.35f, rimColor = "#FFE08A", highlight = 0.3f) + + /** A rose-tinted viewing glass (colour-filter lens), no magnification. */ + val ROSE = OGGlassSpec(name = "rose", zoom = 1f, radius = 0.26f, tint = "#FF4D8D", tintStrength = 0.4f, rimColor = "#FFD6E6", highlight = 0.35f) + + /** All built-in presets, keyed by lowercase name. */ + val presets: Map = + listOf(MAGNIFIER, READER, MINIFIER, AMBER, ROSE).associateBy { it.name!! } +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/glass/OGGlassSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/glass/OGGlassSpec.kt new file mode 100644 index 0000000..e15da1e --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/glass/OGGlassSpec.kt @@ -0,0 +1,55 @@ +package com.solidkey.painpoints.glass + +/** + * **Glass** — a round "look-behind-glass" lens laid over any content: a **magnifier** ([zoom] > 1), a + * **minifier** ([zoom] < 1), and/or **coloured glass** ([tint]). Plain data, like the rest of the + * library's packs; decode + presets on [OGGlass], draw with [OGGlassView]. + * + * **How it works (60fps, no shader).** The content under the glass is recorded once into a + * `GraphicsLayer` (the same trick as `Modifier.ogGlow`); the lens then re-draws that layer **scaled about + * the lens centre**, clipped to a circle, so whatever sits beneath appears magnified (or shrunk). A + * translucent [tint] + a soft rim + a specular highlight sell the "piece of glass" look. No per-pixel + * work and no runtime shader (the library floors those out), so it's cheap and cross-platform. + * + * An accessibility win as much as an effect: drop it over a board, a photo or a word and older eyes get a + * movable magnifier; flip [zoom] under 1 for a fit-more minifier, or [tint] it for a colour-filter glass. + */ +@kotlinx.serialization.Serializable +data class OGGlassSpec( + val name: String? = null, + /** Magnification under the glass: `> 1` magnifies, `< 1` minifies, `1` is a plain (tinted) pane. */ + val zoom: Float = 2f, + /** Lens radius as a fraction of the shorter side of the view (`0..1`). Sets the lens **half-height**; + * [aspect] stretches the width from it. */ + val radius: Float = 0.22f, + /** Lens **width ÷ height**. `1` = a round lens (default). `> 1` = a horizontal **pill / bar** — wider + * than tall, so it spans a whole row of content cleanly instead of clipping them on a circle's curve. */ + val aspect: Float = 1f, + /** Corner rounding as a fraction of the lens half-height (`0..1`). `1` = fully round ends (a circle when + * [aspect] is 1, a stadium/capsule when wider); `0` = a sharp-cornered rectangle. */ + val cornerRadius: Float = 1f, + /** Glass tint — any SVG colour string. Fully transparent (`#00000000`) = clear magnifier. */ + val tint: String = "#00000000", + /** How strongly the [tint] is laid over the magnified content (`0..1`). */ + val tintStrength: Float = 0.25f, + /** The glass edge / frame colour — any SVG colour string. */ + val rimColor: String = "#FFFFFF", + /** Rim thickness as a fraction of the lens radius (`0..0.5`). */ + val rimWidth: Float = 0.05f, + /** Rim opacity (`0..1`). */ + val rimAlpha: Float = 0.8f, + /** Specular sheen strength across the top-left of the glass (`0..1`, `0` = none). */ + val highlight: Float = 0.3f, +) { + /** Clamp every field into its sane range (lenient model/designer input never breaks a frame). */ + fun sanitized(): OGGlassSpec = copy( + zoom = zoom.coerceIn(0.2f, 6f), + radius = radius.coerceIn(0.03f, 1f), + aspect = aspect.coerceIn(0.2f, 5f), + cornerRadius = cornerRadius.coerceIn(0f, 1f), + tintStrength = tintStrength.coerceIn(0f, 1f), + rimWidth = rimWidth.coerceIn(0f, 0.5f), + rimAlpha = rimAlpha.coerceIn(0f, 1f), + highlight = highlight.coerceIn(0f, 1f), + ) +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/glass/OGGlassView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/glass/OGGlassView.kt new file mode 100644 index 0000000..af363ce --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/glass/OGGlassView.kt @@ -0,0 +1,230 @@ +package com.solidkey.painpoints.glass + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.solidkey.painpoints.ai.toSpecColor +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Lay a **fixed, movable glass lens** ([spec]) over [content] — a magnifier / minifier / coloured glass + * (see [OGGlassSpec]). The lens stays put where you leave it, and — crucially — its body is + * **pointer-transparent**, so you can keep interacting with the content underneath (place a word, tap a + * cell) while the glass is parked over an area. Move it only by dragging its **grip handle**; dismiss it + * with the **✕** (wired to [onRemove]). Set [enabled] to `false` to hide the glass entirely. + * + * The content is recorded once into a `GraphicsLayer` and the lens redraws it scaled about its centre, + * clipped to a circle, plus tint + rim + sheen — GPU work only, no shader, cross-platform. Place it as + * the outermost wrapper around whatever should be magnifiable. + */ +@Composable +fun OGGlass( + spec: OGGlassSpec, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onRemove: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val s = remember(spec) { spec.sanitized() } + val tint = remember(s) { s.tint.toSpecColor() ?: Color.Transparent } + val rim = remember(s) { s.rimColor.toSpecColor() ?: Color.White } + val layer = rememberGraphicsLayer() + var size by remember { mutableStateOf(IntSize.Zero) } + var center by remember { mutableStateOf(Offset.Unspecified) } + + Box(modifier.onSizeChanged { size = it }) { + // The content, with the lens drawn ON TOP but WITHOUT consuming any pointer input — so taps and + // drags fall straight through to the content beneath the glass. + Box( + Modifier.fillMaxSize().drawWithContent { + layer.record { this@drawWithContent.drawContent() } + val lens = if (enabled) { + computeLens(s, resolveCenter(center, this.size.width, this.size.height), this.size.width, this.size.height) + } else null + if (lens != null) { + // Draw the base content everywhere EXCEPT the lens disc, so inside the circle only the + // magnified copy shows. Without this hole the un-magnified base would show through wherever + // the content is transparent (e.g. beside a sprite) → a "doubled" ghost under the lens. + clipPath(lens.path, clipOp = ClipOp.Difference) { drawLayer(layer) } + drawGlass(s, layer, lens, tint, rim) + } else { + drawLayer(layer) + } + }, + ) { content() } + + // Position the ✕ and grip off the SAME lens shape the Canvas draws (computeLens coerces the centre + // on screen). Recomputing the raw centre here would leave the handles behind when the lens is pushed + // to an edge — they'd drift off the rim. The rim may now spill past an edge, so clamp each knob's + // anchor onto the screen too, keeping it tappable even when its corner of the rim is off-screen. + val handleLens = if (enabled) { + computeLens(s, resolveCenter(center, size.width.toFloat(), size.height.toFloat()), size.width.toFloat(), size.height.toFloat()) + } else null + if (handleLens != null) { + val c = handleLens.center + val halfW = handleLens.halfW + val halfH = handleLens.halfH + fun anchor(dx: Float, dy: Float, knob: Int): IntOffset { + val ax = (c.x + dx).coerceIn(knob / 2f, size.width - knob / 2f) + val ay = (c.y + dy).coerceIn(knob / 2f, size.height - knob / 2f) + return IntOffset(ax.roundToInt() - knob / 2, ay.roundToInt() - knob / 2) + } + // Grip handle on the BOTTOM-CENTRE of the rim — the ONLY thing that moves the lens. Centre (not a + // corner) so it tracks the clamped centre.x and never jams against a side wall: that let the lens + // pan symmetrically to BOTH edges (a right-side grip hit the right wall early and blocked the lens + // from reaching the rightmost content, while the left panned freely). + val grip = 44 + Box( + Modifier + .offset { anchor(0f, halfH * 0.86f, grip) } + .size(grip.dp) + .clip(CircleShape) + .background(rim.copy(alpha = 0.92f)) + .pointerInput(size) { + detectDragGestures { change, drag -> + change.consume() + val from = if (center == Offset.Unspecified) c else center + center = from + drag + } + }, + ) { Canvas(Modifier.fillMaxSize()) { drawMoveGlyph(GripInk) } } + + // Remove ✕ on the TOP-CENTRE of the rim (mirrors the grip; clear of both side walls). + if (onRemove != null) { + val x = 34 + Box( + Modifier + .offset { anchor(0f, -halfH * 0.86f, x) } + .size(x.dp) + .clip(CircleShape) + .background(rim.copy(alpha = 0.92f)) + .clickable { onRemove() }, + ) { Canvas(Modifier.fillMaxSize()) { drawCloseGlyph(Color.Black) } } + } + } + } +} + +/** Lens centre in px: the stored [center], or the middle of a [w]×[h] view before the first move. */ +private fun resolveCenter(center: Offset, w: Float, h: Float): Offset = + if (center == Offset.Unspecified) Offset(w / 2f, h / 2f) else center + +/** The resolved lens shape — its on-screen [center], half-[width]/half-[height] and the rounded-rect + * [path] — shared by the base-content hole-punch and the lens draw so they line up exactly. A circle is + * just the square (half-width == half-height), fully-rounded case. */ +private class LensShape(val center: Offset, val halfW: Float, val halfH: Float, val path: Path) + +/** Resolve the lens shape for a [w]×[h] view, clamping [rawCenter] so the lens **centre** stays on screen + * — the rim is allowed to spill past an edge so the loupe can be dragged right onto edge content (a wide + * pill lens whose centre was pinned ≥ halfW could never reach the first column, magnifying its leading + * letters off-screen → "UYRUK" read as "YRUK"). [OGGlassSpec.radius] sets the half-height, [aspect] the + * width, [cornerRadius] the end rounding. Returns `null` when the view or size is degenerate. */ +private fun computeLens(s: OGGlassSpec, rawCenter: Offset, w: Float, h: Float): LensShape? { + if (w <= 0f || h <= 0f) return null + val halfH = s.radius * min(w, h) + if (halfH <= 0f) return null + val halfW = halfH * s.aspect + val cx = rawCenter.x.coerceIn(0f, w) + val cy = rawCenter.y.coerceIn(0f, h) + val c = Offset(cx, cy) + val corner = s.cornerRadius * minOf(halfW, halfH) + val path = Path().apply { + addRoundRect(RoundRect(Rect(c.x - halfW, c.y - halfH, c.x + halfW, c.y + halfH), CornerRadius(corner, corner))) + } + return LensShape(c, halfW, halfH, path) +} + +/** Draw the lens: magnified (scaled) copy of [layer] clipped to the lens shape, then tint, rim and sheen. */ +private fun DrawScope.drawGlass( + s: OGGlassSpec, + layer: GraphicsLayer, + lens: LensShape, + tint: Color, + rim: Color, +) { + val c = lens.center + // The magnified (or minified) content under the glass: scale the recorded layer about the lens centre. + clipPath(lens.path) { + scale(s.zoom, s.zoom, pivot = c) { drawLayer(layer) } + } + // Coloured glass: lay the tint over the lensed content (clear tint → nothing). + val tintA = tint.alpha * s.tintStrength + if (tintA > 0f) drawPath(lens.path, tint.copy(alpha = tintA)) + // Specular sheen across the top-left, selling the curved-glass look. + if (s.highlight > 0f) { + val sheen = Brush.radialGradient( + colorStops = arrayOf(0f to Color.White.copy(alpha = 0.55f * s.highlight), 1f to Color.Transparent), + center = Offset(c.x - lens.halfW * 0.3f, c.y - lens.halfH * 0.3f), + radius = maxOf(lens.halfW, lens.halfH) * 1.25f, + ) + drawPath(lens.path, brush = sheen) + } + // The glass rim / frame. + val rimW = (s.rimWidth * lens.halfH) + if (rimW > 0f && s.rimAlpha > 0f) { + drawPath(lens.path, rim.copy(alpha = s.rimAlpha), style = Stroke(width = rimW)) + } +} + +/** Fixed dark ink for the grip dots — a readable charcoal that contrasts on the light knob regardless of + * the lens tint (a pale tint would wash the dots out). */ +private val GripInk = Color(0xFF3A3A3A) + +/** A **drag-handle** glyph for the grip — a 2×3 grid of dots, the universal "grab here to move" affordance. + * (Deliberately NOT a "+" or arrows, which read as zoom / direction rather than "hold me".) */ +private fun DrawScope.drawMoveGlyph(color: Color) { + val c = Offset(size.width / 2f, size.height / 2f) + val dot = size.minDimension * 0.07f + val gx = size.minDimension * 0.17f // column half-spacing + val gy = size.minDimension * 0.17f // row spacing + for (col in intArrayOf(-1, 1)) { + for (row in intArrayOf(-1, 0, 1)) { + drawCircle(color, radius = dot, center = Offset(c.x + col * gx, c.y + row * gy)) + } + } +} + +/** An ✕ glyph for the remove button. */ +private fun DrawScope.drawCloseGlyph(color: Color) { + val c = Offset(size.width / 2f, size.height / 2f) + val a = size.minDimension * 0.24f + val sw = size.minDimension * 0.09f + drawLine(color, Offset(c.x - a, c.y - a), Offset(c.x + a, c.y + a), strokeWidth = sw, cap = StrokeCap.Round) + drawLine(color, Offset(c.x - a, c.y + a), Offset(c.x + a, c.y - a), strokeWidth = sw, cap = StrokeCap.Round) +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/motion/OGClothStrip.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/motion/OGClothStrip.kt index cc0cc95..f4a7c43 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/motion/OGClothStrip.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/motion/OGClothStrip.kt @@ -38,6 +38,22 @@ data class OGClothSpec( val pinTail: Boolean = false, ) +/** + * Overlay a named [OGCloths.materials] fabric onto this strip's **feel** (weight / stiffness / air-catch), + * keeping its **form** (nodes, length, pinTail, seed) and its **prevailing wind** (the scene's breeze). + * Unknown name → returned unchanged. So: scarf-shaped silk that flutters in the scene's wind is + * `OGCloths.preset("scarf")!!.withMaterial("silk")`. + */ +fun OGClothSpec.withMaterial(name: String): OGClothSpec { + val m = OGCloths.material(name) ?: return this + return copy( + gravity = m.gravity, + damping = m.damping, + windAmplitude = m.windAmplitude, + windFrequency = m.windFrequency, + ) +} + /** * A lightweight **1-D Verlet cloth strip** — the "real fabric" member of the motion-dynamics family: a * scarf, a cape edge, a flag, a banner or a hair strand. Unlike [OGFollowChain] (N springs in series, @@ -204,6 +220,29 @@ object OGCloths { /** Look up a [presets] entry by name (case-insensitive), or `null` if there is none. */ fun preset(name: String): OGClothSpec? = presets[name.lowercase()] + /** + * **Named fabrics** — the *material* half of a cloth, so you can author by feel ("a cotton shirt", + * "silk", "denim") instead of raw numbers. Each entry tunes only the **material** knobs — weight + * ([OGClothSpec.gravity]), stiffness ([OGClothSpec.damping]) and how much it catches air + * ([OGClothSpec.windAmplitude] / [OGClothSpec.windFrequency]). It deliberately leaves **form** (nodes, + * length, pinTail) and the **prevailing [OGClothSpec.wind]** (that's the *scene's* breeze, not the + * fabric) to the caller — overlay a material onto any shape with [withMaterial]: + * `OGCloths.preset("scarf")!!.withMaterial("silk").copy(wind = -0.5f)`. + */ + val materials: Map = linkedMapOf( + "cotton" to OGClothSpec(name = "cotton", gravity = 0.95f, damping = 0.95f, windAmplitude = 0.35f, windFrequency = 0.7f, wind = 0f), + "silk" to OGClothSpec(name = "silk", gravity = 0.6f, damping = 0.6f, windAmplitude = 0.7f, windFrequency = 0.9f, wind = 0f), + "chiffon" to OGClothSpec(name = "chiffon", gravity = 0.45f, damping = 0.5f, windAmplitude = 0.85f, windFrequency = 1.1f, wind = 0f), + "satin" to OGClothSpec(name = "satin", gravity = 0.7f, damping = 0.65f, windAmplitude = 0.6f, windFrequency = 0.85f, wind = 0f), + "linen" to OGClothSpec(name = "linen", gravity = 0.8f, damping = 0.8f, windAmplitude = 0.5f, windFrequency = 0.8f, wind = 0f), + "wool" to OGClothSpec(name = "wool", gravity = 1.1f, damping = 1.05f, windAmplitude = 0.3f, windFrequency = 0.55f, wind = 0f), + "denim" to OGClothSpec(name = "denim", gravity = 1.3f, damping = 1.2f, windAmplitude = 0.2f, windFrequency = 0.5f, wind = 0f), + "leather" to OGClothSpec(name = "leather", gravity = 1.5f, damping = 1.3f, windAmplitude = 0.12f, windFrequency = 0.4f, wind = 0f), + ) + + /** Look up a [materials] feel-template by name (case-insensitive), or `null` if there is none. */ + fun material(name: String): OGClothSpec? = materials[name.lowercase()] + /** * Deterministically **re-simulate a fresh head-pinned strip to time [timeSec]** at the fixed internal * substep, with the head anchor given by [anchorAt] (seconds → point). This is the compositor's way to @@ -248,9 +287,14 @@ object OGCloths { "pinTail": } + Think in MATERIALS: lighter + lower-damping fabric flutters more (silk/chiffon: gravity~0.5, + damping~0.6, windAmplitude~0.8); heavier + stiffer barely moves (denim/leather/wool: gravity~1.3, + damping~1.2, windAmplitude~0.2); cotton shirt sits in between (gravity~0.95, damping~0.95). + Examples: - {"name":"silk scarf","nodes":16,"length":0.5,"gravity":0.7,"damping":0.7,"windAmplitude":0.8} + {"name":"silk scarf","nodes":16,"length":0.5,"gravity":0.6,"damping":0.6,"windAmplitude":0.8} {"name":"heavy flag","nodes":16,"length":0.5,"gravity":0.4,"wind":1.1,"pinTail":true} + {"name":"cotton shirt hem","nodes":14,"length":0.4,"gravity":0.95,"damping":0.95,"windAmplitude":0.35} Output the JSON only, with no prose and no markdown fences. diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticleSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticleSpec.kt index b3e59e4..0b05090 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticleSpec.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticleSpec.kt @@ -2,6 +2,7 @@ package com.solidkey.painpoints.particle import androidx.compose.ui.graphics.Color import com.solidkey.painpoints.ai.toSpecColor +import com.solidkey.painpoints.fx.OGFxNoise import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt @@ -59,6 +60,29 @@ data class OGParticleSpec( val gravityY: Float = 0f, /** Velocity damping per second (`0` = none; ~`1` = strong air drag). */ val drag: Float = 0f, + /** + * **Prevailing wind** — a steady horizontal acceleration (normalized units/s², `+` blows right) + * that gusts ride on top of. Unlike [gravityX] (a dead-constant push), wind is the base of a living + * breeze: pair it with [windAmplitude] for turbulent gusts. `0` = calm (default keeps old output). + * Mirrors [com.solidkey.painpoints.motion.OGClothStrip]'s wind model so cloth and particles blow alike. + */ + val wind: Float = 0f, + /** Peak **turbulent gust** acceleration layered on top of [wind] (a fractal-noise field, so gusts + * swell and fade organically and no two particles lurch in lockstep). `0` = steady wind only. */ + val windAmplitude: Float = 0f, + /** How fast the gusts wander — cycles/second-ish of the noise field. Higher = choppier, flicky gusts. */ + val windFrequency: Float = 0.7f, + /** Seed for the gust pattern — change it for a different-but-repeatable breeze. */ + val windSeed: Int = 0, + /** + * **Curl-noise turbulence** (Bridson 2007) — advects every particle through a divergence-free + * velocity field built as the curl of a fractal-noise potential (`v = (∂P/∂y, −∂P/∂x)`). + * Divergence-free means the field has no sources or sinks, so particles swirl and eddy naturally + * without ever bunching up or thinning out artificially — the real-smoke look. The displacement + * scales with [speed] too, so faster effects churn harder. `0` (default) = off, byte-identical to + * the old integrator. Try `0.2..0.5` for smoke/leaves/embers. + */ + val turbulenceStrength: Float = 0f, /** Size at birth, as a fraction of the box's shorter side. */ val startSize: Float = 0.04f, /** Size at death, as a fraction of the box's shorter side. */ @@ -77,7 +101,7 @@ data class OGParticleSpec( val spinDeg: Float = 0f, /** Spin spread as a fraction (`0..1`). */ val spinJitter: Float = 1f, - /** Particle shape: `circle` · `square` · `triangle` · `star` · `petal` · `teardrop`. */ + /** Particle shape: `circle` · `square` · `triangle` · `star` · `petal` · `teardrop` · `sparkle`. */ val shape: String = "circle", /** Seed for the deterministic jitter — change it for a different-but-repeatable burst. */ val seed: Int = 0, @@ -87,7 +111,7 @@ data class OGParticleSpec( } /** The particle silhouette, resolved once from [OGParticleSpec.shape]. */ -enum class OGParticleShape { CIRCLE, SQUARE, TRIANGLE, STAR, PETAL, TEARDROP } +enum class OGParticleShape { CIRCLE, SQUARE, TRIANGLE, STAR, PETAL, TEARDROP, SPARKLE } /** * A live, compiled [OGParticleSpec] — a fixed-capacity particle simulation. Build via @@ -128,6 +152,7 @@ class OGParticleSystem(private val spec: OGParticleSpec) { "star" -> OGParticleShape.STAR "petal", "leaf" -> OGParticleShape.PETAL "teardrop", "drop", "droplet" -> OGParticleShape.TEARDROP + "sparkle", "kira", "diamond" -> OGParticleShape.SPARKLE else -> OGParticleShape.CIRCLE } @@ -146,6 +171,13 @@ class OGParticleSystem(private val spec: OGParticleSpec) { private var rng = 0 private var emitAccum = 0f private var bursted = false + /** Monotonic seconds, advanced each [update]; drives the wind-gust + curl-noise fields. */ + private var windClockSec = 0f + /** Whether any wind force is configured — lets the integrator skip the noise work (and stay + * byte-identical to the old output) when wind is off. */ + private val hasWind = spec.wind != 0f || spec.windAmplitude != 0f + /** Whether curl-noise turbulence is on — off (default) skips the field entirely. */ + private val hasTurb = spec.turbulenceStrength > 0f init { reset() } @@ -154,6 +186,7 @@ class OGParticleSystem(private val spec: OGParticleSpec) { count = 0 emitAccum = 0f bursted = false + windClockSec = 0f rng = (spec.seed * -1640531527) xor 0x9E3779B1.toInt() if (rng == 0) rng = 1 } @@ -205,6 +238,7 @@ class OGParticleSystem(private val spec: OGParticleSpec) { fun update(dtMs: Float) { if (cap == 0) return val dt = (dtMs.coerceAtLeast(0f)) / 1000f + if (hasWind || hasTurb) windClockSec += dt // Age, integrate, and compact out the dead in one pass. var w = 0 @@ -213,6 +247,31 @@ class OGParticleSystem(private val spec: OGParticleSpec) { if (age[r] >= life[r]) continue // dead: don't carry it forward vx[r] += spec.gravityX * dt vy[r] += spec.gravityY * dt + if (hasWind) { + // Prevailing wind + a turbulent gust sampled per-particle from a fractal-noise field, so + // gusts swell/fade and vary across the view (a light vertical flutter makes leaves tumble). + val t = windClockSec * spec.windFrequency + val gustX = spec.windAmplitude * OGFxNoise.fbm(t + x[r] * 2.3f, y[r] * 2.3f, 2, spec.windSeed) + val gustY = spec.windAmplitude * 0.5f * OGFxNoise.fbm(y[r] * 2.3f, t + x[r] * 2.3f, 2, spec.windSeed + 101) + vx[r] += (spec.wind + gustX) * dt + vy[r] += gustY * dt + } + if (hasTurb) { + // Curl noise (Bridson 2007): v = curl P = (∂P/∂y, −∂P/∂x) of a scalar noise potential, + // via central differences. Divergence-free by construction → particles eddy and swirl + // without artificial clumping or thinning. Applied as a positional advection so it + // layers cleanly on top of the ballistic velocity. + val t = windClockSec * 0.35f + val px = x[r] * CURL_FREQ + val py = y[r] * CURL_FREQ + val cvx = (curlPotential(px, py + CURL_EPS, t) - curlPotential(px, py - CURL_EPS, t)) / + (2f * CURL_EPS) + val cvy = -(curlPotential(px + CURL_EPS, py, t) - curlPotential(px - CURL_EPS, py, t)) / + (2f * CURL_EPS) + val k = spec.turbulenceStrength * spec.speed * dt + x[r] += cvx * k + y[r] += cvy * k + } if (spec.drag > 0f) { val damp = (1f - spec.drag * dt).coerceAtLeast(0f) vx[r] *= damp; vy[r] *= damp @@ -249,9 +308,17 @@ class OGParticleSystem(private val spec: OGParticleSpec) { } } + /** The scalar noise potential P for curl noise — a slowly time-evolving fractal field. */ + private fun curlPotential(px: Float, py: Float, t: Float): Float = + OGFxNoise.fbm(px + t, py - t * 0.7f, 2, spec.seed + 977) + private companion object { const val DEG = 0.017453292f const val TWO_PI = 6.2831855f + /** Spatial frequency of the curl-noise potential over the normalized 0..1 view. */ + const val CURL_FREQ = 2.2f + /** Central-difference step for the curl (in potential-field units). */ + const val CURL_EPS = 0.05f fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t fun packArgb(a: Float, r: Float, g: Float, b: Float): Int { val ai = (a.coerceIn(0f, 1f) * 255f + 0.5f).toInt() diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticleView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticleView.kt index 1eb1f2d..ebdd282 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticleView.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticleView.kt @@ -108,4 +108,10 @@ internal fun unitOutline(shape: OGParticleShape): List? = when (shape) { Offset(0f, -1f), Offset(0.5f, -0.1f), Offset(0.65f, 0.4f), Offset(0.38f, 0.82f), Offset(0f, 1f), Offset(-0.38f, 0.82f), Offset(-0.65f, 0.4f), Offset(-0.5f, -0.1f), ) + // Kira-kira diamond sparkle — 4 elongated points at the cardinal directions, very thin. + // Models the astroid cusp look (Spencer 1995) as a polygon for GPU compositing. + OGParticleShape.SPARKLE -> listOf( + Offset(0f, -1f), Offset(0.07f, -0.07f), Offset(1f, 0f), Offset(0.07f, 0.07f), + Offset(0f, 1f), Offset(-0.07f, 0.07f), Offset(-1f, 0f), Offset(-0.07f, -0.07f), + ) } diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticles.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticles.kt index 1e82550..e12e4c3 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticles.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/particle/OGParticles.kt @@ -46,6 +46,9 @@ object OGParticles { Fields (all optional, sensible defaults): maxParticles, emissionRate (per sec), burst (one-shot count), lifetimeMs, lifetimeJitter, x, y, spawnRadius, angleDeg, spreadDeg, speed, speedJitter, gravityX, gravityY, drag, + wind (steady horizontal breeze, + = right), windAmplitude (turbulent gust strength on top), + windFrequency (how fast gusts wander), windSeed, + turbulenceStrength (0..1 curl-noise eddying — swirling smoke/leaf motion; 0 = off), startSize, endSize, sizeJitter, startColor, endColor, startAlpha, endAlpha, spinDeg, spinJitter, shape ("circle"|"square"|"triangle"|"star"|"petal"|"teardrop"), seed. @@ -53,6 +56,7 @@ object OGParticles { Examples: {"name":"confetti","shape":"square","burst":150,"emissionRate":0,"angleDeg":270,"spreadDeg":160,"speed":0.55,"gravityY":0.55,"spinDeg":220,"startColor":"#FF5252","endAlpha":0} {"name":"embers","emissionRate":40,"angleDeg":270,"spreadDeg":40,"speed":0.2,"gravityY":-0.05,"startColor":"#FFE082","endColor":"#FF6D00","endAlpha":0,"startSize":0.01,"endSize":0.003} + {"name":"blizzard","shape":"circle","emissionRate":90,"y":-0.1,"spawnRadius":0.6,"angleDeg":90,"spreadDeg":30,"speed":0.14,"gravityY":0.05,"wind":0.25,"windAmplitude":0.4,"windFrequency":0.8,"startColor":"#FFFFFF","endAlpha":0} Output the JSON only, with no prose and no markdown fences. @@ -88,6 +92,16 @@ object OGParticles { startColor = "#FFFFFF", startAlpha = 0.9f, endAlpha = 0f, seed = 11, ) + /** A driving snowstorm — flakes sweep sideways on a steady wind with turbulent gusts swelling on top. */ + val BLIZZARD = OGParticleSpec( + name = "blizzard", shape = "circle", maxParticles = 320, burst = 0, emissionRate = 95f, + lifetimeMs = 7000f, lifetimeJitter = 0.3f, x = 0.4f, y = -0.1f, spawnRadius = 0.7f, + angleDeg = 90f, spreadDeg = 36f, speed = 0.16f, speedJitter = 0.7f, gravityY = 0.05f, + drag = 0.05f, wind = 0.28f, windAmplitude = 0.45f, windFrequency = 0.85f, windSeed = 4, + startSize = 0.009f, endSize = 0.009f, sizeJitter = 0.7f, + startColor = "#FFFFFF", startAlpha = 0.95f, endAlpha = 0f, seed = 19, + ) + /** Dreamy out-of-focus dots drifting gently in every direction. */ val BOKEH = OGParticleSpec( name = "bokeh", shape = "circle", maxParticles = 60, burst = 0, emissionRate = 12f, @@ -132,7 +146,7 @@ object OGParticles { name = "embers", shape = "circle", maxParticles = 200, burst = 0, emissionRate = 55f, lifetimeMs = 1600f, lifetimeJitter = 0.4f, x = 0.5f, y = 0.72f, spawnRadius = 0.25f, angleDeg = 270f, spreadDeg = 50f, speed = 0.22f, speedJitter = 0.7f, gravityY = -0.06f, - drag = 0.2f, startSize = 0.012f, endSize = 0.003f, sizeJitter = 0.5f, + drag = 0.2f, turbulenceStrength = 0.20f, startSize = 0.012f, endSize = 0.003f, sizeJitter = 0.5f, startColor = "#FFE082", endColor = "#FF6D00", endAlpha = 0f, seed = 31, ) @@ -141,23 +155,45 @@ object OGParticles { name = "droplets", shape = "teardrop", maxParticles = 160, burst = 0, emissionRate = 40f, lifetimeMs = 1400f, lifetimeJitter = 0.3f, x = 0.5f, y = 0.3f, spawnRadius = 0.25f, angleDeg = 90f, spreadDeg = 60f, speed = 0.18f, speedJitter = 0.6f, gravityY = 0.5f, - drag = 0.1f, startSize = 0.01f, endSize = 0.006f, sizeJitter = 0.4f, + drag = 0.1f, turbulenceStrength = 0.15f, startSize = 0.01f, endSize = 0.006f, sizeJitter = 0.4f, startColor = "#E1F5FE", endColor = "#4FC3F7", startAlpha = 0.9f, endAlpha = 0f, seed = 41, ) + /** Underwater bubbles rising buoyantly, fading to near-white as they near the surface. */ + val BUBBLES = OGParticleSpec( + name = "bubbles", shape = "circle", maxParticles = 60, burst = 0, emissionRate = 20f, + lifetimeMs = 2200f, lifetimeJitter = 0.45f, x = 0.5f, y = 1.05f, spawnRadius = 0.5f, + angleDeg = 270f, spreadDeg = 30f, speed = 0.08f, speedJitter = 0.6f, gravityY = -0.05f, + startSize = 0.018f, endSize = 0.018f, sizeJitter = 0.65f, + startColor = "#B3E5FC", endColor = "#E1F5FE", startAlpha = 0.55f, endAlpha = 0f, seed = 53, + ) + /** Autumn leaves drifting down, turning from green to amber. */ val LEAVES = OGParticleSpec( name = "leaves", shape = "petal", maxParticles = 90, burst = 0, emissionRate = 16f, lifetimeMs = 7000f, lifetimeJitter = 0.3f, x = 0.5f, y = -0.1f, spawnRadius = 0.6f, angleDeg = 90f, spreadDeg = 50f, speed = 0.09f, speedJitter = 0.7f, gravityY = 0.015f, - drag = 0.08f, startSize = 0.024f, endSize = 0.024f, sizeJitter = 0.6f, + drag = 0.08f, turbulenceStrength = 0.45f, startSize = 0.024f, endSize = 0.024f, sizeJitter = 0.6f, startColor = "#AED581", endColor = "#FBC02D", startAlpha = 0.95f, endAlpha = 0f, spinDeg = 150f, spinJitter = 1f, seed = 51, ) + /** + * Kira-kira anime sparkles — 4-pointed diamond sparkle bursts (Spencer 1995 astroid). + * Classic use: glass/jewel/magical highlight bursts. Spin fast, dissolve quickly. + */ + val SPARKLES = OGParticleSpec( + name = "sparkles", shape = "sparkle", maxParticles = 60, burst = 12, emissionRate = 0f, + lifetimeMs = 620f, lifetimeJitter = 0.45f, x = 0.5f, y = 0.5f, spawnRadius = 0.08f, + angleDeg = 270f, spreadDeg = 360f, speed = 0.22f, speedJitter = 0.75f, gravityY = -0.04f, + drag = 0.12f, startSize = 0.040f, endSize = 0.010f, sizeJitter = 0.6f, + startColor = "#FFFFFF", endColor = "#FFD700", startAlpha = 1f, endAlpha = 0f, + spinDeg = 480f, spinJitter = 1f, seed = 77, + ) + /** All built-in presets, keyed by lowercase name. */ val presets: Map = listOf( - CONFETTI, SPARKS, SNOW, BOKEH, RAIN, FIREWORKS, - PETALS, EMBERS, DROPLETS, LEAVES, + CONFETTI, SPARKS, SNOW, BLIZZARD, BOKEH, RAIN, FIREWORKS, + PETALS, EMBERS, DROPLETS, BUBBLES, LEAVES, SPARKLES, ).associateBy { it.name!! } } diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/puppet/OGPuppet.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/puppet/OGPuppet.kt new file mode 100644 index 0000000..662541e --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/puppet/OGPuppet.kt @@ -0,0 +1,44 @@ +package com.solidkey.painpoints.puppet + +import com.solidkey.painpoints.ai.OGAiVector + +/** + * The **codec** for [OGPuppetSpec] — decode a JSON puppet pack (authored by a designer, the Layer Cutter + * tool, or a model) into a spec, encode one back, and build a model prompt. Mirrors + * [com.solidkey.painpoints.particle.OGParticles] and [OGAiVector]: the same lenient JSON config and + * tolerant extraction (code fences / stray prose survive), with no network and no AI SDK. + * + * The pack names the base + part sprites by resource; feed the decoded spec to [OGPuppetView], which + * resolves those names with the platform image loader and plays the rig. + */ +object OGPuppet { + /** The shared lenient JSON config (reused from [OGAiVector] so every data layer agrees). */ + val json get() = OGAiVector.json + + /** Decode a JSON puppet pack into its [OGPuppetSpec]. Throws on malformed input. */ + fun decode(text: String): OGPuppetSpec = + json.decodeFromString(OGPuppetSpec.serializer(), OGAiVector.extractJson(text)) + + /** [decode] but returns `null` instead of throwing on malformed input. */ + fun decodeOrNull(text: String): OGPuppetSpec? = runCatching { decode(text) }.getOrNull() + + /** Serialize a puppet pack to JSON (compact; defaults omitted). */ + fun encode(spec: OGPuppetSpec): String = json.encodeToString(OGPuppetSpec.serializer(), spec) + + /** + * Build a ready-to-send instruction constraining a model to emit exactly the puppet-pack JSON [decode] + * expects. Wire the returned string into your model; pass the reply to [decode]. + */ + fun puppetPrompt(instruction: String): String = """ + You output ONLY a JSON object describing a cut-out puppet for the KMPMedia library. + + A puppet is a still "base" sprite plus "parts" (movers, back-to-front). Every sprite shares the + base frame. Each part: res (resource name), pivotX/pivotY (its joint, 0..1 of the frame), + swingDeg/speed/phase (a faint idle rotation about the joint), gaze (slide toward the look target, + fraction of width), head (true for the one head part, which turns toward the look target). + Spec: base, parts[], aspect (base w/h), grounded (true = stands and bends about its feet; false = + hovers in place). Output JSON only, no prose. + + Task: $instruction + """.trimIndent() +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/puppet/OGPuppetSpec.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/puppet/OGPuppetSpec.kt new file mode 100644 index 0000000..f346eea --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/puppet/OGPuppetSpec.kt @@ -0,0 +1,148 @@ +package com.solidkey.painpoints.puppet + +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.sin + +/** + * The **data-defined cut-out puppet**: a hand-drawn figure cut into transparent part-layers (a still + * **base** plus jointed **movers**) and reassembled as a live raster puppet — each mover rotating about + * the joint it was pinned at, breathing, leaning and *watching* a look target. It is the raster sibling + * of the library's generative data packs ([com.solidkey.painpoints.particle.OGParticleSpec] particles, + * [com.solidkey.painpoints.shape.OGParametricSpec] shapes): a designer (or the Layer Cutter tool) authors + * a `.puppet` pack of joints + motion, and the lib brings it to life — no per-app rigging code. + * + * Decode/encode + a ready-made preset live on [OGPuppet]; drop it on screen with + * [com.solidkey.painpoints.puppet.OGPuppetView]. The pure transform math ([OGPuppetRig]) is split out so it + * is deterministic and testable without Compose (same `tSec` + `look` → same pose on Android and iOS). + * + * **Coordinates.** Every part sprite shares the base's frame (each cut part baked/placed on the same canvas, + * so a mover needs only its joint). [OGPuppetPart.pivotX]/[OGPuppetPart.pivotY] are the joint as a `0..1` + * fraction of that frame; [OGPuppetPart.tuckX]/[tuckY] and [OGPuppetPart.gaze] are fractions of the frame + * width/height. A **look** value is `-1..1` horizontally (−1 = hard left, +1 = hard right, 0 = centre). + */ +@kotlinx.serialization.Serializable +data class OGPuppetPart( + /** Resource name of this part's transparent sprite (resolved by the platform image loader). */ + val res: String, + /** The joint this mover rotates about, as a `0..1` fraction of the shared frame. */ + val pivotX: Float = 0.5f, + val pivotY: Float = 0.5f, + /** Idle rotation amplitude (degrees) about the pivot — a faint life-swing (`0` = still). */ + val swingDeg: Float = 0f, + /** Idle swing rate + phase, so sibling parts don't move in lockstep. */ + val speed: Float = 1f, + val phase: Float = 0f, + /** A static offset of this part within the frame (fraction of width/height). */ + val tuckX: Float = 0f, + val tuckY: Float = 0f, + /** This part WATCHES the look target: it slides toward it by `gaze·look` (fraction of width) — e.g. coal + * eyes/a mouth with no head to turn. */ + val gaze: Float = 0f, + /** This part is the HEAD: it turns toward the look target (a tilt + a mirror-flip in place about + * [OGPuppetSpec.headCenterX]/[headCenterY]) instead of a plain idle swing. At most one part should set this. */ + val head: Boolean = false, +) + +/** + * A whole cut-out puppet: the still [base] sprite plus its [parts] (movers, back→front), the frame [aspect], + * and the feel of its idle life. [grounded] = it stands/sits on a surface (bends gently about its feet, + * never lifts); otherwise it hovers in place (a slow vertical float + airborne tilt), like a flying figure. + */ +@kotlinx.serialization.Serializable +data class OGPuppetSpec( + val base: String, + val parts: List = emptyList(), + /** The base frame's width / height. */ + val aspect: Float = 1f, + val grounded: Boolean = false, + /** Breathing: a gentle whole-figure scale pulse. */ + val breatheAmp: Float = 0.02f, + val breatheSpeed: Float = 0.5f, + /** Whole-figure horizontal lean toward the look target (fraction of width). */ + val leanAmp: Float = 0.06f, + /** Head-turn tilt toward the look target (degrees), for a part with [OGPuppetPart.head]. */ + val lookTurnDeg: Float = 12f, + /** The head's own centre (frame fraction) — a head mirror-flips about this to turn in place. */ + val headCenterX: Float = 0.5f, + val headCenterY: Float = 0.5f, + /** Whether a head part mirror-FLIPS to turn (a profile/one-sided face reads as turning when flipped). + * `false` for a symmetric forward face (e.g. a bee) — it just tilts toward the look, no flip. */ + val headMirror: Boolean = true, + /** Invert the head's look DIRECTION (both the turn-tilt and the mirror). The right sign depends on which + * way the drawn head faces; set `true` if the head turns AWAY from the look target instead of toward it. */ + val headLookInvert: Boolean = false, + /** Grounded idle bend amplitude (degrees) + how much MORE it leans toward the look target. */ + val bendDeg: Float = 4f, + val bendLookDeg: Float = 5f, + /** Flying hover: vertical float (fraction of height) + airborne tilt sway (degrees). */ + val hoverAmp: Float = 0.05f, + val tiltDeg: Float = 1.5f, + val name: String? = null, +) { + /** The head part, if any (the one that turns toward the look target). */ + val headPart: OGPuppetPart? get() = parts.firstOrNull { it.head } +} + +/** + * A transient whole-figure pose laid **on top** of the idle life — the host app folds its own game beats + * (a score pop, a tap-spotlight, an emotional reaction) into one of these. Identity = [Idle]. + * [dy] is a fraction of height, [scale] a multiplier, [rot] degrees. + */ +data class OGPuppetPose(val dy: Float = 0f, val scale: Float = 1f, val rot: Float = 0f) { + companion object { val Idle = OGPuppetPose() } +} + +/** + * The puppet's pure, deterministic pose math — no Compose, no platform. Given a clock [tSec] and a look + * target, it yields the whole-figure transform and each part's local transform. [OGPuppetView] renders + * these; tests pin them. Same inputs → identical output on every platform. + */ +object OGPuppetRig { + private const val TAU = (2.0 * PI).toFloat() + + /** The resolved look `-1..1`: the host's [look] when it is watching something, else a slow idle glance. */ + fun effectiveLook(look: Float?, tSec: Float): Float = + look?.coerceIn(-1f, 1f) ?: (sin(tSec * 0.7f) * 0.6f) + + /** Whole-figure vertical offset (fraction of height): a hover float for a flying figure, `0` if grounded. */ + fun figureFloat(spec: OGPuppetSpec, tSec: Float): Float = + if (spec.grounded) 0f else sin(tSec * 2.5f) * spec.hoverAmp + + /** Whole-figure breathing scale multiplier. */ + fun figureBreathe(spec: OGPuppetSpec, tSec: Float): Float = + 1f + sin(TAU * spec.breatheSpeed * tSec) * spec.breatheAmp + + /** Whole-figure horizontal lean (fraction of width) toward the look target. */ + fun figureLean(spec: OGPuppetSpec, look: Float): Float = look * spec.leanAmp + + /** Whole-figure rotation (degrees): a grounded side-to-side bend about the feet, or an airborne tilt. */ + fun figureRotation(spec: OGPuppetSpec, tSec: Float, look: Float): Float = + if (spec.grounded) sin(tSec * 1.05f) * spec.bendDeg + look * spec.bendLookDeg + else sin(tSec * 1.3f) * spec.tiltDeg + + /** The look as the head sees it — inverted when [OGPuppetSpec.headLookInvert] is set (the drawn head faces + * the other way), so "toward the move" means the same direction for the tilt and the mirror. */ + private fun headLook(spec: OGPuppetSpec, look: Float): Float = if (spec.headLookInvert) -look else look + + /** A mover's local rotation (degrees) about its pivot: a head adds a damped look-turn, others idle-swing. */ + fun partAngle(spec: OGPuppetSpec, part: OGPuppetPart, tSec: Float, look: Float): Float { + val idle = part.swingDeg * sin(2.2f * part.speed * tSec + part.phase) + return if (part.head) idle * 0.25f + headLook(spec, look) * spec.lookTurnDeg else idle + } + + /** A head mirror-flips toward the look direction to read as turning in place (kept near full width so the + * flip never looks squished) — only when [OGPuppetSpec.headMirror] is set (a one-sided face). A symmetric + * face (headMirror = false) and every non-head part stay at width 1. */ + fun partScaleX(spec: OGPuppetSpec, part: OGPuppetPart, look: Float): Float { + if (!(part.head && spec.headMirror)) return 1f + val l = headLook(spec, look) + return (if (l >= 0f) 1f else -1f) * (0.9f + 0.1f * abs(l)) + } + + /** A mover's horizontal offset (fraction of width): its static tuck plus a gaze slide toward the look. */ + fun partOffsetX(part: OGPuppetPart, look: Float): Float = part.tuckX + part.gaze * look + + /** A mover's vertical offset (fraction of height): its static tuck. */ + fun partOffsetY(part: OGPuppetPart): Float = part.tuckY +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/puppet/OGPuppetView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/puppet/OGPuppetView.kt new file mode 100644 index 0000000..e18f89e --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/puppet/OGPuppetView.kt @@ -0,0 +1,127 @@ +package com.solidkey.painpoints.puppet + +import androidx.compose.animation.core.withInfiniteAnimationFrameNanos +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import com.solidkey.painpoints.image.loading.loadImageFromResource + +/** + * Plays a cut-out [OGPuppetSpec] as a live raster puppet: the still base with each jointed mover rotating + * about its pivot, breathing, leaning and watching the [look] target — the whole-figure + per-part transforms + * come from the deterministic [OGPuppetRig]. The host feeds a `look` (`-1..1`, or `null` for a gentle idle + * glance) and an optional [pose] (its own game beat — a score pop, a tap, a reaction) folded on top. + * + * This painter overload takes the already-decoded [base] + [parts] painters (and an optional [headPainter] to + * swap the head's sprite, e.g. an expression frame), so the host keeps control of loading / sprite-swapping. + * The [OGPuppetView] spec overload below loads the sprites for you by resource name — the fully self-contained + * "pure-lib" path for a puppet with no runtime sprite swaps. + * + * Size + aspect: the puppet fills [modifier]'s box; give it a box matching [OGPuppetSpec.aspect] so the base + * frame isn't letterboxed (then every mover's pivot fraction lines up with the drawn pixels). + */ +@Composable +fun OGPuppetView( + base: Painter?, + parts: List, + spec: OGPuppetSpec, + modifier: Modifier = Modifier, + look: Float? = null, + pose: OGPuppetPose = OGPuppetPose.Idle, + headPainter: Painter? = null, + playing: Boolean = true, +) { + var tSec by remember { mutableStateOf(0f) } + LaunchedEffect(playing) { + if (!playing) return@LaunchedEffect + var start = 0L + while (true) { + withInfiniteAnimationFrameNanos { nanos -> + if (start == 0L) start = nanos + tSec = (nanos - start) / 1_000_000_000f + } + } + } + + val look01 = OGPuppetRig.effectiveLook(look, tSec) + val breathe = OGPuppetRig.figureBreathe(spec, tSec) + val float = OGPuppetRig.figureFloat(spec, tSec) + val lean = OGPuppetRig.figureLean(spec, look01) + val bend = OGPuppetRig.figureRotation(spec, tSec, look01) + + Box( + modifier.graphicsLayer { + // Grounded puppets pivot about their feet and never lift; flying ones float in place. + transformOrigin = TransformOrigin(0.5f, if (spec.grounded) 1f else 0.5f) + translationX = lean * size.width + translationY = (float + pose.dy) * size.height + scaleX = breathe * pose.scale + scaleY = breathe * pose.scale + rotationZ = bend + pose.rot + }, + ) { + // The still base (Fit: the frame fills the box, so each mover's pivot fraction lines up with the pixels). + base?.let { + Image(it, contentDescription = spec.name, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit) + } + spec.parts.forEachIndexed { i, part -> + val painter = if (part.head && headPainter != null) headPainter else parts.getOrNull(i) + painter?.let { p -> + val angle = OGPuppetRig.partAngle(spec, part, tSec, look01) + val sx = OGPuppetRig.partScaleX(spec, part, look01) + val offX = OGPuppetRig.partOffsetX(part, look01) + val offY = OGPuppetRig.partOffsetY(part) + Image( + p, contentDescription = null, contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize().graphicsLayer { + transformOrigin = + if (part.head) TransformOrigin(spec.headCenterX, spec.headCenterY) + else TransformOrigin(part.pivotX, part.pivotY) + rotationZ = angle + scaleX = sx + translationX = offX * size.width + translationY = offY * size.height + }, + ) + } + } + } +} + +/** + * The self-loading overload: give it a decoded [OGPuppetSpec] and it resolves the base + part sprites by + * resource name (via the platform image loader) and plays the rig — the fully "pure-lib" path for a puppet + * with no runtime sprite swaps. For a puppet that swaps a sprite at runtime (e.g. an expression head frame), + * use the painter overload above and pass the current painters yourself. + */ +@Composable +fun OGPuppetView( + spec: OGPuppetSpec, + modifier: Modifier = Modifier, + look: Float? = null, + pose: OGPuppetPose = OGPuppetPose.Idle, + playing: Boolean = true, +) { + var base by remember(spec.base) { mutableStateOf(null) } + loadImageFromResource(spec.base) { base = it } + val parts = remember(spec) { mutableStateListOf().apply { repeat(spec.parts.size) { add(null) } } } + spec.parts.forEachIndexed { i, part -> + loadImageFromResource(part.res) { parts[i] = it } + } + OGPuppetView( + base = base, parts = parts, spec = spec, modifier = modifier, + look = look, pose = pose, headPainter = null, playing = playing, + ) +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/shape/OGSnowflake.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/shape/OGSnowflake.kt new file mode 100644 index 0000000..845fe23 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/shape/OGSnowflake.kt @@ -0,0 +1,106 @@ +package com.solidkey.painpoints.shape + +import kotlin.math.cos +import kotlin.math.sin + +/** One straight twig of a snowflake crystal, in a unit space centred at `(0,0)` (roughly `[-1,1]`). */ +data class OGSnowflakeSegment(val a: OGPoint, val b: OGPoint) + +/** + * A **procedural snowflake generator** — a deterministic `seed` → a unique, n-fold **symmetric ice + * crystal** as plain line segments, no two alike. It is the vector-geometry sibling of the lib's other + * generative shape makers ([OGParametricShape] / [OGPolygonShape]): pure maths, zero dependency, no + * Compose — the caller turns the segments into a `Path` (strokes read as a crystal; snowflakes are line + * figures, not filled blobs). + * + * How it's built: one **arm** is grown along +Y — a central spine plus a few **side-twigs** at seeded + * positions / angles / lengths. Each twig is a tiny **recursive fractal** (a seeded dendrite): at + * [depth] > 0 every twig sprouts its own smaller side-twigs, and those sprout again, exactly the way a + * real stellar-dendrite crystal grows. Only the right half of the arm is grown and then **mirrored** + * left↔right so the arm is bilaterally symmetric; that whole arm is copied by rotating it around the + * centre [arms] times (6 = a real snowflake's hexagonal symmetry), so every arm matches while the + * crystal stays unique per seed. [depth] 0 = the old plain-prong look; 2 (default) reads as a crystal. + */ +object OGSnowflake { + + /** + * Generate a crystal for [seed]. [arms] = radial symmetry (6 is natural); [branches] = primary + * side-twigs per arm; [depth] = fractal recursion — how many times a twig sprouts smaller twigs + * (0 = straight prongs like before, 2 = a convincing dendrite, capped at 4). The result is in a unit + * circle centred at `(0,0)`; scale + translate it to place it. + */ + fun generate( + seed: Int, + arms: Int = 6, + branches: Int = 3, + depth: Int = 2, + ): List { + val a = arms.coerceIn(3, 12) + val b = branches.coerceIn(0, 6) + val d = depth.coerceIn(0, 4) + var rng = (seed * -1640531527) xor 0x9E3779B1.toInt() + fun next(): Float { // xorshift → [0,1) + rng = rng xor (rng shl 13); rng = rng xor (rng ushr 17); rng = rng xor (rng shl 5) + return ((rng ushr 8) and 0xFFFFFF) / 16_777_216f + } + + // Grow only the RIGHT half of one arm (twigs leaning +x off the +Y spine), recursing per twig, + // then mirror it to the left — guarantees the arm is bilaterally symmetric about its spine. + val tip = 0.55f + next() * 0.45f // spine length (0.55..1.0) + val right = ArrayList() + for (i in 0 until b) { + val t = 0.25f + (i + next()) / b.coerceAtLeast(1) * 0.65f // position along the spine (0.25..0.9) + val baseY = t * tip + val len = (1f - t) * tip * (0.35f + next() * 0.4f) // shorter twigs toward the tip + val lean = (28f + next() * 34f) * DEG // 28°..62° off the spine + growTwig(right, 0f, baseY, lean, len, d) { next() } + } + + val arm = ArrayList(1 + right.size * 2) + arm += OGSnowflakeSegment(OGPoint(0f, 0f), OGPoint(0f, tip)) // spine (on the mirror axis) + arm += right + for (s in right) arm += OGSnowflakeSegment(OGPoint(-s.a.x, s.a.y), OGPoint(-s.b.x, s.b.y)) + + // Rotate the finished arm into all [a] directions. + val out = ArrayList(arm.size * a) + for (k in 0 until a) { + val ang = k * (TWO_PI / a) + val c = cos(ang); val s = sin(ang) + for (seg in arm) { + out += OGSnowflakeSegment(rotate(seg.a, c, s), rotate(seg.b, c, s)) + } + } + return out + } + + /** + * Emit one twig from ([bx],[by]) pointing [ang] off the +Y axis with length [len], then — while + * [depth] > 0 — recursively sprout smaller twigs along it to either side, the fractal that turns a + * bare prong into a dendrite. [next] is the shared seeded RNG so the whole crystal stays deterministic. + */ + private fun growTwig( + out: MutableList, + bx: Float, by: Float, ang: Float, len: Float, depth: Int, next: () -> Float, + ) { + val tx = bx + sin(ang) * len + val ty = by + cos(ang) * len + out += OGSnowflakeSegment(OGPoint(bx, by), OGPoint(tx, ty)) + if (depth <= 0) return + val kids = if (depth >= 2) 2 else 1 + for (i in 0 until kids) { + val t = 0.35f + (i + next() * 0.9f) / kids * 0.5f // where along this twig (0.35..0.85) + val px = bx + (tx - bx) * t + val py = by + (ty - by) * t + val childLen = len * (0.4f + next() * 0.25f) // children are ~40–65% of the parent + val sub = (24f + next() * 30f) * DEG // sprout angle off the parent twig + growTwig(out, px, py, ang + sub, childLen, depth - 1, next) // outward child + growTwig(out, px, py, ang - sub, childLen, depth - 1, next) // inward child + } + } + + private fun rotate(p: OGPoint, c: Float, s: Float): OGPoint = + OGPoint(p.x * c - p.y * s, p.x * s + p.y * c) + + private const val DEG = 0.017453292f + private const val TWO_PI = 6.2831855f +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGCutoutRig.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGCutoutRig.kt new file mode 100644 index 0000000..27ab4f4 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGCutoutRig.kt @@ -0,0 +1,137 @@ +package com.solidkey.painpoints.stage + +import com.solidkey.painpoints.ai.OGAiCapability +import com.solidkey.painpoints.ai.OGAiVector +import com.solidkey.painpoints.compositor.OGEasing +import com.solidkey.painpoints.compositor.OGKeyframedFloat +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * A **cut-out puppet rig**: a base image plus mover part-images, each carrying a joint [OGCutoutLayer.pivotX]/ + * [OGCutoutLayer.pivotY] (a fraction of the canvas). This is what the *Layer Cutter* tool produces once a + * drawing is separated into clean transparent part-layers — the data half of "a flat drawing becomes a + * movable puppet". + * + * ### How a limb swings about its real joint with the plain stage transform + * [OGStageLayer] rotates about the layer **centre** (there is no per-layer transform origin). So the mover + * frames are authored **pivot-centred** — the part is drawn on a full-canvas frame with its joint at the + * frame centre. Then [OGOfflineCutoutRigAuthor] rotates the layer about its centre (= the joint) and + * *afterwards* translates the joint to where it belongs ([OGStageView] applies `rotation` about centre, then + * `translation`). A rigid post-rotation translate keeps the swing anchored at the joint. No library render + * change is needed — the whole geometry lives in the frame + the x/y offset here. + */ +@Serializable +public data class OGCutoutRig( + public val width: Int = 1080, + public val height: Int = 1080, + public val durationMs: Long = 2600L, + public val fps: Int = 60, + /** Optional base coat behind every layer (SVG colour string); `null` = transparent. */ + public val background: String? = null, + public val layers: List = emptyList(), +) + +/** One part of a [OGCutoutRig]: the drawn [resource], its [role], and the joint it turns around. */ +@Serializable +public data class OGCutoutLayer( + public val id: String, + /** Bundled/registered image name the player resolves (defaults to [id]). */ + public val resource: String = id, + /** `"base"` (stays joined, still) or `"mover"` (swings about its joint). */ + public val role: String = "mover", + /** Joint X as a fraction of the canvas width (the mover frame is authored with the joint at centre). */ + public val pivotX: Float = 0.5f, + /** Joint Y as a fraction of the canvas height. */ + public val pivotY: Float = 0.5f, + /** Mover swing amplitude in degrees (ignored for a base). */ + public val swingDeg: Float = 9f, +) { + public val isBase: Boolean get() = role.equals("base", ignoreCase = true) +} + +/** JSON codec for [OGCutoutRig], reusing the lenient AI codec (tolerates fences/prose, case-insensitive). */ +public object OGCutoutRigs { + public val json: Json = OGAiVector.json + public fun encode(rig: OGCutoutRig): String = json.encodeToString(OGCutoutRig.serializer(), rig) + public fun decode(text: String): OGCutoutRig = + json.decodeFromString(OGCutoutRig.serializer(), OGAiVector.extractJson(text)) + public fun decodeOrNull(text: String): OGCutoutRig? = runCatching { decode(text) }.getOrNull() +} + +/** + * The **deterministic floor**: a cut-out rig → a looping [OGStage] puppet, with no AI and no network. The + * base sits still; each mover swings gently about its joint, desynced so the figure reads as alive. This is + * the honest fallback under [OGCutoutRigCapability] — the plugin always produces *something* movable even + * when no agent answers. Mirrors `OGOfflineStageAuthor`'s build-from-[OGStages]-builders style. + */ +public class OGOfflineCutoutRigAuthor { + public fun bake(rig: OGCutoutRig): OGStage { + val w = rig.width.coerceAtLeast(1) + val h = rig.height.coerceAtLeast(1) + val dur = rig.durationMs.coerceAtLeast(400L) + var moverIndex = 0 + val layers = rig.layers.map { l -> + if (l.isBase) { + OGStages.imageStill(id = l.id, resource = l.resource, widthFrac = 1f, heightFrac = 1f) + } else { + // Flip the lead direction and nudge amplitude per mover so limbs don't move in lockstep. + val sign = if (moverIndex % 2 == 0) 1f else -1f + val amp = l.swingDeg * (0.8f + 0.2f * (moverIndex % 3)) + moverIndex++ + OGStages.imageStill( + id = l.id, resource = l.resource, widthFrac = 1f, heightFrac = 1f, + x = OGKeyframedFloat.const(l.pivotX - 0.5f), // move the centred joint to its spot… + y = OGKeyframedFloat.const(l.pivotY - 0.5f), + rotationDeg = loopSwing(dur, amp * sign), // …then swing about it (rotation-first) + onTap = OGStages.tap(OGTapAction.BOUNCE), // tappable: poke a limb to react + ) + } + } + return OGStage.of( + *layers.toTypedArray(), + width = w, height = h, durationMs = dur, fps = rig.fps, background = rig.background, + ) + } + + /** rest → +amp → rest → −amp → rest across one loop, eased so it reverses smoothly at the extremes. */ + private fun loopSwing(dur: Long, amp: Float): OGKeyframedFloat { + val q = dur / 4 + return OGStages.eased( + OGEasing.EASE_IN_OUT, + 0L to 0f, q to amp, 2 * q to 0f, 3 * q to -amp, dur to 0f, + ) + } +} + +/** + * The **"cut-out puppet" capability** — a first-class [OGAiCapability] for the manifest, next to + * `OGStageAuthoring.illustrate`. The app sends an [OGCutoutRig]; a subscribed agent (over your nexum + * channel) returns a moving [OGStage], or [OGOfflineCutoutRigAuthor] does it on-device. Compose it into a + * manifest with `OGAiManifest.of(app, listOf(OGStageAuthoring.illustrate, OGCutoutRigCapability))`. + */ +public val OGCutoutRigCapability: OGAiCapability = + object : OGAiCapability { + override val id: String = "cutoutRig" + override val title: String = "Cut-out puppet" + override val description: String = + "Turn a cut-out rig (a base image plus mover part-images, each with a joint pivot given as a " + + "fraction of the canvas) into a looping OGStage puppet whose parts swing about their joints. " + + "Each mover frame is already pivot-centred, so animate its rotationDeg about the layer centre " + + "and offset its x/y by (pivot − 0.5) to seat the joint." + override val outputFormat: String = "one OGStage JSON object" + override fun promptFor(input: OGCutoutRig): String = + OGStages.stagePrompt( + "Author a lively looping puppet from this cut-out rig. The base layer stays still and " + + "joined; every mover swings gently about its joint, desynced. Rig:\n" + + OGCutoutRigs.encode(input), + ) + override fun decode(reply: String): OGStage? = OGStages.decodeOrNull(reply) + } + +/** Agent-first helper: use the agent's [agentReply] OGStage if it parses, else the deterministic floor. */ +public fun bakeCutoutRig( + rig: OGCutoutRig, + agentReply: String? = null, + floor: OGOfflineCutoutRigAuthor = OGOfflineCutoutRigAuthor(), +): OGStage = agentReply?.let { OGStages.decodeOrNull(it) } ?: floor.bake(rig) diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGOfflineStageAuthor.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGOfflineStageAuthor.kt index 5c39001..aa58e7e 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGOfflineStageAuthor.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGOfflineStageAuthor.kt @@ -56,6 +56,21 @@ public class OGOfflineStageAuthor( public fun direct(instruction: String): OGStage { val text = instruction.lowercase() val seed = (instruction.hashCode() and 0x7fffffff) + + // When the ask is a pure natural disaster event without an explicit character subject, + // yield the full cinematic multi-layer event stage. + if (subjectLayer(text) == null) { + if (text.hasAny("earthquake", "quake", "tremor", "seismic")) { + return com.solidkey.painpoints.fx.OGEvents.earthquakeStage(width, height, durationMs, fps) + } + if (text.hasAny("flood", "tsunami", "deluge")) { + return com.solidkey.painpoints.fx.OGEvents.floodStage(width, height, durationMs, fps) + } + if (text.hasAny("storm", "thunder", "lightning", "tempest")) { + return com.solidkey.painpoints.fx.OGEvents.thunderStage(width, height, durationMs, fps) + } + } + val p = paletteFor(text) val durSec = durationMs / 1000f @@ -131,6 +146,18 @@ public class OGOfflineStageAuthor( accent = "#8fb8ff", particleTint = "#9fc4ff", particleBlend = OGBlendMode.SCREEN, title = "#dce8ff", swingTo = null, celestial = false, ) + t.hasAny("earthquake", "quake", "tremor", "seismic", "chasm") -> Palette( + background = "#120b08", skyTop = "#1a0f0a", skyBottom = "#3e2723", + groundFar = "#2b1a14", groundNear = "#1a0f0a", groundDeep = "#0a0503", + accent = "#ffe082", particleTint = "#d7ccc8", particleBlend = OGBlendMode.NORMAL, + title = "#ffe082", swingTo = null, celestial = false, + ) + t.hasAny("flood", "tsunami", "deluge", "inundation") -> Palette( + background = "#06101e", skyTop = "#040b15", skyBottom = "#102a45", + groundFar = "#0d233a", groundNear = "#09192a", groundDeep = "#030910", + accent = "#80d8ff", particleTint = "#b3e5fc", particleBlend = OGBlendMode.SCREEN, + title = "#80d8ff", swingTo = null, celestial = false, + ) t.hasAny("night", "midnight", "starry", "space", "galaxy", "cosmos", "moon") -> Palette( background = "#060a16", skyTop = "#03060f", skyBottom = "#132a52", groundFar = "#122038", groundNear = "#0a1526", groundDeep = "#04070f", @@ -245,6 +272,22 @@ public class OGOfflineStageAuthor( // ── Particles ───────────────────────────────────────────────────────────────────────────────── private fun particlesFor(t: String, p: Palette, seed: Int): OGParticleSpec = when { + t.hasAny("earthquake", "quake", "tremor", "seismic") -> + OGParticleSpec( + name = "earth_dust", shape = "circle", maxParticles = 140, emissionRate = 60f, + x = 0.50f, y = 0.80f, spawnRadius = 0.35f, angleDeg = 270f, spreadDeg = 70f, + speed = 0.18f, gravityY = -0.04f, drag = 0.2f, + startSize = 0.018f, endSize = 0.032f, + startColor = p.particleTint, endColor = "#5D4037", startAlpha = 0.8f, seed = seed, + ) + t.hasAny("flood", "tsunami", "deluge") -> + OGParticles.DROPLETS.copy( + maxParticles = 240, emissionRate = 95f, + x = 0.45f, y = 0.62f, spawnRadius = 0.50f, + angleDeg = 60f, spreadDeg = 45f, + speed = 0.42f, speedJitter = 0.5f, gravityY = 0.40f, + startColor = p.particleTint, endColor = "#81D4FA", seed = seed, + ) t.hasAny("rain", "storm", "thunder", "shower", "drizzle") -> OGParticles.RAIN.copy( maxParticles = 700, emissionRate = 420f, speed = 1.6f, gravityY = 1.0f, @@ -327,7 +370,7 @@ public class OGOfflineStageAuthor( // ── Title card ──────────────────────────────────────────────────────────────────────────────── private fun titleLayer(instruction: String, t: String, p: Palette): OGStageLayer { - val boil = t.hasAny("neon", "cyber", "rush", "speed", "fire", "flame", "lava", "glitch") + val boil = t.hasAny("neon", "cyber", "rush", "speed", "fire", "flame", "lava", "glitch", "thunder", "storm", "earthquake", "flood") val style = if (boil) { OGStyleSpec(name = "vibe", ops = listOf(OGStyleOp(op = "boil", amplitude = 0.018f, boilFps = 9f))) } else { diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStage.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStage.kt index 1e68156..2d1f487 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStage.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStage.kt @@ -5,13 +5,20 @@ import com.solidkey.painpoints.ai.OGSceneSpec import com.solidkey.painpoints.ai.isLive import com.solidkey.painpoints.ai.liveAt import com.solidkey.painpoints.compositor.OGKeyframedFloat +import com.solidkey.painpoints.fx.OGAuroraSpec +import com.solidkey.painpoints.fx.OGCrackSpec +import com.solidkey.painpoints.fx.OGFireflySpec import com.solidkey.painpoints.fx.OGLightningSpec +import com.solidkey.painpoints.fx.OGRippleSpec +import com.solidkey.painpoints.fx.OGShockwaveSpec import com.solidkey.painpoints.fx.OGSlashSpec import com.solidkey.painpoints.fx.OGSpeedLinesSpec +import com.solidkey.painpoints.fx.OGWaveSpec import com.solidkey.painpoints.look.OGBlendMode import com.solidkey.painpoints.look.OGLookSpec import com.solidkey.painpoints.particle.OGParticleSpec import com.solidkey.painpoints.style.OGStyleSpec +import com.solidkey.painpoints.motion.OGClothSpec import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlin.math.max @@ -45,8 +52,8 @@ import kotlin.math.roundToInt * trips losslessly. * * The kinds cover the two halves of the engine (a generated scene + a generative-motion pack), the - * action-FX packs, styled vector [Text] for titles/captions, and a flat fill; `rig` / `cloth` / an - * image bitmap slot in behind the same interface with no change to [OGStage] or [OGStageView]. + * action-FX packs, styled vector [Text] for titles/captions, a flat fill, a wind-blown [Cloth] strip, + * and a raster [Image] cutout — all behind the same interface with no change to [OGStage] or [OGStageView]. */ @Serializable sealed interface OGStageContent { @@ -104,8 +111,137 @@ sealed interface OGStageContent { val family: String? = null, val style: OGStyleSpec? = null, ) : OGStageContent + + /** + * A **raster cutout** — real drawn pixels (a bundled PNG / WebP), not a redrawn vector. This is the + * image-bitmap slot the engine reserved: it lets a hand-drawn character part live on the stage as + * itself. One or more [frames] are shown in order on the layer's local clock (looping), so a cutout + * animates by **frame-swap** (a blink, a mouth shape, a wave) while the layer's own transform tracks + * move it and an [OGStageLayer.onTap] makes it poke-reactive — a paper-doll / Spine-style puppet, + * authored as data. + * + * The image is centred in the layer box and scaled to **fit** within [widthFrac] × [heightFrac] of + * it (aspect preserved), so you place and size it with the layer transform, not by pre-scaling the + * art. Each frame names a resource the **host app bundles** (Android `res/drawable`, iOS bundle), + * resolved by the shared image loader — so unlike the other content kinds a blind describe→movie + * model can't author this one, but an app assembling a puppet from its own assets can. + * + * @param frames the cutout's frames in play order; one frame = a still, several = a frame-swap sprite. + * @param widthFrac / [heightFrac] the fit box as a fraction of the stage (`1` = the whole stage box). + */ + @Serializable + @SerialName("image") + data class Image( + val frames: List, + val widthFrac: Float = 1f, + val heightFrac: Float = 1f, + ) : OGStageContent { + /** True when there is more than one frame — the cutout animates by frame-swap. */ + val isAnimated: Boolean get() = frames.size > 1 + + /** The index of the frame shown at layer-local [timeMs], looping over the total hold span. Safe + * for an empty / single-frame list (returns 0). */ + fun frameIndexAt(timeMs: Long): Int { + if (frames.size <= 1) return 0 + val total = frames.sumOf { it.holdMs.coerceAtLeast(1L) } + if (total <= 0L) return 0 + var t = (timeMs % total).coerceAtLeast(0L) + for (i in frames.indices) { + t -= frames[i].holdMs.coerceAtLeast(1L) + if (t < 0L) return i + } + return frames.lastIndex + } + } + + /** + * A **wind-blown cloth strip** — a scarf, cape edge, banner, or a lock of **long hair** that hangs + * and *flutters*. Wraps the motion family's 1-D Verlet [OGClothSpec] (gravity + prevailing wind + + * turbulent gusts) as a first-class stage layer: the strip's head is pinned at ([anchorX], [anchorY]) + * inside the layer box and streams out on the breeze, deterministic and frame-identical on both + * platforms (it re-simulates from `0` to the layer-local time, so a live frame and an exported frame + * match exactly). Pair it with a particle layer blowing the SAME direction and the whole scene reads + * as one wind — snow sweeps, hair streams, a dress ripples, together. + * + * Pin it onto a character cutout by placing this layer at the hairline / hem / sleeve with the layer + * transform. The anchor is fixed within the box (the layer transform moves it), so the strip responds + * to **wind**, not to the character's own acceleration — inertial drag from motion is out of scope. + * + * @param cloth the fabric's feel (nodes / length / gravity / damping / wind…); reuse an [OGClothSpec] + * preset like `"hair"` or `"scarf"` and `copy()` the wind to taste. + * @param anchorX / [anchorY] the pinned head, normalized `0..1` of the layer box. + * @param tailX / [tailY] the far-end pin, used only when [OGClothSpec.pinTail] (a banner strung taut). + * @param color the ribbon fill (an SVG colour string). + * @param width the ribbon's half-thickness as a fraction of the box's shorter side. + * @param taper taper the ribbon to a point at the free end (a hair lock / pennant) vs a constant + * width (a strap / banner). + */ + @Serializable + @SerialName("cloth") + data class Cloth( + val cloth: OGClothSpec, + val anchorX: Float = 0.5f, + val anchorY: Float = 0.12f, + val tailX: Float = 0.5f, + val tailY: Float = 0.12f, + val color: String = "#FFFFFF", + val width: Float = 0.05f, + val taper: Boolean = true, + ) : OGStageContent + + /** Rolling procedural waves ([wave]) — calm, ocean, flood, lava. */ + @Serializable + @SerialName("wave") + data class Wave(val wave: OGWaveSpec) : OGStageContent + + /** Growing impact fracture / crack lines ([crack]). */ + @Serializable + @SerialName("crack") + data class Crack( + val crack: OGCrackSpec, + val originX: Float = 0.5f, + val originY: Float = 0.5f, + val progress: OGKeyframedFloat = OGKeyframedFloat.const(1f), + ) : OGStageContent + + /** Expanding impact shockwave ring ([shockwave]). */ + @Serializable + @SerialName("shockwave") + data class Shockwave( + val shockwave: OGShockwaveSpec, + val originX: Float = 0.5f, + val originY: Float = 0.5f, + val progress: OGKeyframedFloat = OGKeyframedFloat.const(1f), + ) : OGStageContent + + /** Waving aurora borealis curtain ([aurora]). */ + @Serializable + @SerialName("aurora") + data class Aurora(val aurora: OGAuroraSpec) : OGStageContent + + /** Raindrop ripple capillary rings ([ripple]). */ + @Serializable + @SerialName("ripple") + data class Ripple(val ripple: OGRippleSpec) : OGStageContent + + /** Synchronized Kuramoto fireflies ([firefly]). */ + @Serializable + @SerialName("firefly") + data class Firefly(val firefly: OGFireflySpec) : OGStageContent } +/** + * One raster frame of an [OGStageContent.Image] cutout: a bundled image [resource] held on screen for + * [holdMs] before the next frame. A single frame is a static cutout; a list is a frame-swap sprite — a + * blink, a mouth shape, a wave — advanced on the layer's local clock. Hold the resting frame long and + * the transient frames briefly to read as a natural blink (e.g. `open 3000ms, half 40ms, shut 90ms`). + */ +@Serializable +data class OGImageFrame( + val resource: String, + val holdMs: Long = 120L, +) + /** Style ops whose output changes with time — a [OGStageContent.Text] carrying one of these animates. */ private val TEXT_LIVE_OPS = setOf("boil", "wave", "ripple") @@ -195,6 +331,14 @@ data class OGStage( is OGStageContent.Lightning -> true is OGStageContent.SpeedLines -> true is OGStageContent.Text -> c.style?.ops?.any { it.op.lowercase() in TEXT_LIVE_OPS } == true + is OGStageContent.Image -> c.isAnimated + is OGStageContent.Cloth -> true + is OGStageContent.Wave -> true + is OGStageContent.Crack -> true + is OGStageContent.Shockwave -> true + is OGStageContent.Aurora -> true + is OGStageContent.Ripple -> true + is OGStageContent.Firefly -> true is OGStageContent.Solid -> false } contentLive || diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageCloth.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageCloth.kt new file mode 100644 index 0000000..951f4d9 --- /dev/null +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageCloth.kt @@ -0,0 +1,91 @@ +package com.solidkey.painpoints.stage + +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import com.solidkey.painpoints.ai.toSpecColor +import com.solidkey.painpoints.motion.OGClothStrip +import kotlin.math.min +import kotlin.math.sqrt + +/** + * The single, pure per-frame draw of an [OGStageContent.Cloth] layer — used by BOTH the live + * [OGStageView] and the off-screen [OGStageExport], so a preview frame and an exported frame are + * byte-identical. It **deterministically re-simulates** the Verlet strip from `0` to the layer-local + * time at the fixed internal substep (the strip has no closed form), then paints it as a tapered ribbon. + * + * Cost is O(timeSec) per call — fine for a short stage reel at a handful of nodes; it is a per-frame + * snapshot, not a long-running loop (same contract as the particle/FX export path). + */ +internal fun DrawScope.drawOGStageClothFrame( + c: OGStageContent.Cloth, + localTimeSec: Float, + alpha: Float = 1f, + colorFilter: ColorFilter? = null, + blendMode: BlendMode = BlendMode.SrcOver, +) { + val n = (c.cloth.nodes).coerceAtLeast(2) + val strip = OGClothStrip(c.cloth) + val h = 1f / 120f + strip.reset(c.anchorX, c.anchorY) + var t = 0f + // Re-sim to the target time. step() accumulates dt and runs whole substeps; feeding exactly one + // substep per call keeps it deterministic (same inputs → same frame on Android & iOS). + while (t < localTimeSec) { + strip.step(h, c.anchorX, c.anchorY, c.tailX, c.tailY) + t += h + } + + val w = size.width + val hgt = size.height + val short = min(w, hgt) + val half = (c.width * short).coerceAtLeast(0.5f) + val col = c.color.toSpecColor() ?: Color.White + + // Node centres in pixels. + val xs = FloatArray(n) { strip.x(it) * w } + val ys = FloatArray(n) { strip.y(it) * hgt } + + // Walk the strip, offsetting each node by ±halfWidth along the local normal to make a ribbon; the + // free end tapers to a point when requested (a hair lock / pennant), stays full for a strap/banner. + val path = Path() + // Forward pass along the LEFT edge. + for (i in 0 until n) { + val (nx, ny) = normalAt(xs, ys, i, n) + val hw = half * widthScale(i, n, c.taper) + val px = xs[i] + nx * hw + val py = ys[i] + ny * hw + if (i == 0) path.moveTo(px, py) else path.lineTo(px, py) + } + // Back pass along the RIGHT edge. + for (i in n - 1 downTo 0) { + val (nx, ny) = normalAt(xs, ys, i, n) + val hw = half * widthScale(i, n, c.taper) + path.lineTo(xs[i] - nx * hw, ys[i] - ny * hw) + } + path.close() + drawPath(path, col, alpha = alpha.coerceIn(0f, 1f), style = Fill, colorFilter = colorFilter, blendMode = blendMode) +} + +/** Half-width multiplier along the strip: full at the pinned head, tapering to ~0 at the free end. */ +private fun widthScale(i: Int, n: Int, taper: Boolean): Float { + if (!taper || n <= 1) return 1f + val f = i.toFloat() / (n - 1) + // sqrt keeps the ribbon full most of its length and narrows only near the tip (natural for hair/cloth). + return sqrt((1f - f).coerceIn(0f, 1f)) +} + +/** Unit normal at node [i] (perpendicular to the local tangent); falls back to vertical for a degenerate run. */ +private fun normalAt(xs: FloatArray, ys: FloatArray, i: Int, n: Int): Pair { + val a = if (i > 0) i - 1 else i + val b = if (i < n - 1) i + 1 else i + var tx = xs[b] - xs[a] + var ty = ys[b] - ys[a] + val len = sqrt(tx * tx + ty * ty) + if (len < 1e-4f) { tx = 0f; ty = 1f } else { tx /= len; ty /= len } + // Normal = tangent rotated 90°. + return -ty to tx +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageExport.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageExport.kt index 7d5f370..911e3fe 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageExport.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageExport.kt @@ -23,6 +23,18 @@ import com.solidkey.painpoints.ai.liveAt import com.solidkey.painpoints.ai.toSpecColor import com.solidkey.painpoints.compositor.OGGifDither import com.solidkey.painpoints.compositor.OGGifEncoder +import com.solidkey.painpoints.fx.OGCrackArm +import com.solidkey.painpoints.fx.OGFireflySpec +import com.solidkey.painpoints.fx.OGFireflyState +import com.solidkey.painpoints.fx.OGRippleSpec +import com.solidkey.painpoints.fx.OGRippleState +import com.solidkey.painpoints.fx.crackArms +import com.solidkey.painpoints.fx.drawOGAuroraFrame +import com.solidkey.painpoints.fx.drawOGCrackFrame +import com.solidkey.painpoints.fx.drawOGFireflyFrame +import com.solidkey.painpoints.fx.drawOGRippleFrame +import com.solidkey.painpoints.fx.drawOGShockwaveFrame +import com.solidkey.painpoints.fx.drawOGWaveFrame import com.solidkey.painpoints.compositor.OGMp4Encoder import com.solidkey.painpoints.compositor.cropFlattenFrame import com.solidkey.painpoints.compositor.defaultMp4BitRate @@ -38,6 +50,8 @@ import com.solidkey.painpoints.fx.slashCenterline import com.solidkey.painpoints.fx.speedLineParams import com.solidkey.painpoints.particle.OGParticleSpec import com.solidkey.painpoints.particle.unitOutline +import kotlin.math.pow +import kotlin.random.Random // ============================================================================================ // OGStage EXPORT — the end-to-end loop that completes the unique path: a movie you GENERATE (as @@ -67,11 +81,30 @@ import com.solidkey.painpoints.particle.unitOutline */ fun DrawScope.drawStage(stage: OGStage, timeMs: Long) { stage.background?.toSpecColor()?.let { drawRect(it) } - for (layer in stage.resolveAt(timeMs)) drawStageLayerExport(layer, stage.fps) + // Build crackArms once here — geometry is deterministic (same every frame) but computing + // it inside drawStageLayerExport would re-allocate on every frame during export. + val crackCache = buildCrackArmsCache(stage) + for (layer in stage.resolveAt(timeMs)) drawStageLayerExport(layer, stage.fps, crackCache) +} + +/** Pre-compute crack arm geometry for every Crack layer in [stage], keyed by content identity. */ +private fun buildCrackArmsCache(stage: OGStage): Map> { + val map = HashMap>() + for (layer in stage.layers) { + val c = layer.content + if (c is OGStageContent.Crack) { + map[c] = crackArms(c.crack.sanitized(), c.originX, c.originY) + } + } + return map } /** One resolved layer, transformed + painted with its opacity / look / blend. */ -private fun DrawScope.drawStageLayerExport(r: OGResolvedStageLayer, fps: Int) { +private fun DrawScope.drawStageLayerExport( + r: OGResolvedStageLayer, + fps: Int, + crackCache: Map> = emptyMap(), +) { if (r.opacity <= 0f) return val w = size.width val h = size.height @@ -101,6 +134,57 @@ private fun DrawScope.drawStageLayerExport(r: OGResolvedStageLayer, fps: Int) { is OGStageContent.Slash -> fxGroup(r, w, h) { drawExportSlash(c.slash, r.localTimeMs) } is OGStageContent.Lightning -> fxGroup(r, w, h) { drawExportLightning(c.lightning, r.localTimeMs) } is OGStageContent.SpeedLines -> fxGroup(r, w, h) { drawExportSpeedLines(c.speedLines, r.localTimeMs) } + + // A raster cutout draws real bundled pixels through the Compose image loader — a recomposing, + // async path with no synchronous off-screen equivalent in this DrawScope, so the frame-by-frame + // exporter skips it (it still bakes the rest of the timeline). Capture an image-puppet stage + // from the live player instead; a sync decoder for baking cutouts can slot in here later. + is OGStageContent.Image -> Unit + + // A wind-blown cloth/hair strip bakes through the SAME deterministic per-frame draw the live + // view uses (re-simulated from 0 to the layer-local time), with the layer's opacity/look/blend. + is OGStageContent.Cloth -> + drawOGStageClothFrame(c, r.localTimeMs / 1000f, r.opacity, r.look.cf(), r.blend.toBlendMode()) + + is OGStageContent.Wave -> fxGroup(r, w, h) { + val s = c.wave.sanitized() + val deep = s.deepColor.toSpecColor() ?: Color(0xFF1565C0) + val shallow = s.shallowColor.toSpecColor() ?: Color(0xFF4FC3F7) + drawOGWaveFrame(s, r.localTimeMs / 1000f, deep, shallow) + } + is OGStageContent.Crack -> fxGroup(r, w, h) { + val s = c.crack.sanitized() + // Use the pre-built cache to avoid re-computing geometry on every frame + val arms = crackCache[c] ?: crackArms(s, c.originX, c.originY) + drawOGCrackFrame(s, arms, c.originX, c.originY, c.progress.valueAt(r.localTimeMs), w, h) + } + is OGStageContent.Shockwave -> fxGroup(r, w, h) { + drawOGShockwaveFrame(c.shockwave, c.originX, c.originY, c.progress.valueAt(r.localTimeMs)) + } + is OGStageContent.Aurora -> fxGroup(r, w, h) { + val s = c.aurora.sanitized() + val bot = s.bottomColor.toSpecColor() ?: Color(0xFF00E676) + val mid = s.midColor.toSpecColor() ?: Color(0xFF00BCD4) + val top = s.topColor.toSpecColor() ?: Color(0xFFCE93D8) + drawOGAuroraFrame(s, r.localTimeMs / 1000f, bot, mid, top) + } + // Ripple: simulate a deterministic rain pattern from the spec's seed. Spawns at random + // positions using a fixed cadence so the export looks like ambient rainfall/impacts. + is OGStageContent.Ripple -> fxGroup(r, w, h) { + val s = c.ripple.sanitized() + val ringColor = s.color.toSpecColor() ?: Color(0xFF4FC3F7) + val crestColor = s.crestColor.toSpecColor() ?: Color.White + val state = buildRippleStateAt(s, r.localTimeMs) + drawOGRippleFrame(s, state, r.localTimeMs, ringColor, crestColor) + } + // Firefly: simulate Kuramoto state from t=0 to localTimeMs with fixed 16ms steps. + is OGStageContent.Firefly -> fxGroup(r, w, h) { + val s = c.firefly.sanitized() + val coreColor = s.color.toSpecColor() ?: Color(0xFFCCFF90) + val haloColor = s.glowColor.toSpecColor() ?: Color(0xFFF1F8E9) + val state = buildFireflyStateAt(s, r.localTimeMs) + drawOGFireflyFrame(s, state, coreColor, haloColor) + } } } } @@ -322,3 +406,40 @@ fun OGStage.exportMp4( throw t } } + +// ── Ripple / Firefly deterministic bake helpers ─────────────────────────────────────────────── + +/** + * Build an [OGRippleState] populated with deterministic spawn events up to [localTimeMs]. + * Uses the spec's seed to generate a steady ambient spawn cadence (~4/sec) at random positions, + * so the export looks like active rainfall without needing live tap/spawn input. + */ +private fun buildRippleStateAt(spec: OGRippleSpec, localTimeMs: Long): OGRippleState { + val state = OGRippleState() + val rng = Random(spec.seed.toLong()) + val intervalMs = 250L // ~4 drops/second + var t = 0L + while (t <= localTimeMs) { + val cx = 0.05f + rng.nextFloat() * 0.90f + val cy = 0.05f + rng.nextFloat() * 0.90f + state.spawn(cx, cy, t) + t += intervalMs + (rng.nextFloat() * 150f).toLong() + } + state.cull(localTimeMs, spec) + return state +} + +/** + * Build an [OGFireflyState] advanced to [localTimeMs] by stepping with 16ms ticks. + * Deterministic from the spec's seed — identical on every export frame. + */ +private fun buildFireflyStateAt(spec: OGFireflySpec, localTimeMs: Long): OGFireflyState { + val state = OGFireflyState(spec) + val stepMs = 16f + val steps = (localTimeMs / stepMs).toInt() + val dt = stepMs / 1000f + repeat(steps) { state.update(spec, dt) } + val remainder = (localTimeMs % stepMs.toLong()).toFloat() / 1000f + if (remainder > 0f) state.update(spec, remainder) + return state +} diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageView.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageView.kt index a1e5ebe..7470fa5 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageView.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStageView.kt @@ -1,11 +1,14 @@ package com.solidkey.painpoints.stage import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -20,11 +23,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import com.solidkey.painpoints.fx.OGAuroraView +import com.solidkey.painpoints.fx.OGCrackView +import com.solidkey.painpoints.fx.OGFireflyView +import com.solidkey.painpoints.fx.OGRippleView +import com.solidkey.painpoints.fx.OGShockwaveView +import com.solidkey.painpoints.fx.OGWaveView import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import com.solidkey.painpoints.image.loading.loadImageFromResource import com.solidkey.painpoints.ai.OGPlacedShapeSpec import com.solidkey.painpoints.ai.OGSceneSpec import com.solidkey.painpoints.ai.animatedAt @@ -246,6 +258,69 @@ private fun StageLayerBox( is OGStageContent.Text -> TextLayerCanvas(c, r.localTimeMs, Modifier.fillMaxSize()) + + is OGStageContent.Image -> + ImageLayerBox(c, r.localTimeMs, Modifier.fillMaxSize()) + + is OGStageContent.Cloth -> + ClothLayerCanvas(c, r.localTimeMs, Modifier.fillMaxSize()) + + is OGStageContent.Wave -> + OGWaveView(c.wave, Modifier.fillMaxSize(), playing = livePlay) + + is OGStageContent.Crack -> + OGCrackView(c.crack, Modifier.fillMaxSize(), originX = c.originX, originY = c.originY, progress = c.progress.valueAt(r.localTimeMs)) + + is OGStageContent.Shockwave -> + OGShockwaveView(c.shockwave, Modifier.fillMaxSize(), cx = c.originX, cy = c.originY, progress = c.progress.valueAt(r.localTimeMs)) + + is OGStageContent.Aurora -> + OGAuroraView(c.aurora, Modifier.fillMaxSize(), playing = livePlay) + + is OGStageContent.Ripple -> + OGRippleView(c.ripple, Modifier.fillMaxSize(), playing = livePlay) + + is OGStageContent.Firefly -> + OGFireflyView(c.firefly, Modifier.fillMaxSize(), playing = livePlay) + } + } +} + +/** A wind-blown cloth/hair strip at its layer-local time — drawn through the SAME deterministic + * [drawOGStageClothFrame] the export uses, so preview and exported frames match exactly. */ +@Composable +private fun ClothLayerCanvas(c: OGStageContent.Cloth, timeMs: Long, modifier: Modifier) { + Canvas(modifier) { + drawOGStageClothFrame(c, timeMs / 1000f) + } +} + +/** + * Draw a **raster-cutout** layer: every [frames][OGStageContent.Image.frames] resource is loaded once + * (the shared loader caches by name, so a blink re-shows frames with no re-decode or flicker), and the + * frame for the layer-local [timeMs] is picked and drawn — centred and fit within the content's + * `widthFrac × heightFrac` of the box. The layer's transform / grade / blend / opacity are applied by + * the caller ([StageLayerBox]), so this draws the pixels straight (the same split as the other kinds). + * Transparent until the first decode arrives (no placeholder box), so a cutout composites cleanly. + */ +@Composable +private fun ImageLayerBox(c: OGStageContent.Image, timeMs: Long, modifier: Modifier) { + val loaded = remember(c.frames) { mutableStateMapOf() } + for (f in c.frames) { + loadImageFromResource(f.resource) { p -> loaded[f.resource] = p } + } + val current = c.frames.getOrNull(c.frameIndexAt(timeMs))?.resource + val painter = current?.let { loaded[it] } + Box(modifier, contentAlignment = Alignment.Center) { + if (painter != null) { + Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth(c.widthFrac.coerceIn(0f, 1f)) + .fillMaxHeight(c.heightFrac.coerceIn(0f, 1f)), + ) } } } diff --git a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStages.kt b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStages.kt index 4c4f0b8..f0360b1 100644 --- a/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStages.kt +++ b/library/src/commonMain/kotlin/com/solidkey/painpoints/stage/OGStages.kt @@ -5,11 +5,18 @@ import com.solidkey.painpoints.ai.OGSceneSpec import com.solidkey.painpoints.compositor.OGEasing import com.solidkey.painpoints.compositor.OGKeyframe import com.solidkey.painpoints.compositor.OGKeyframedFloat +import com.solidkey.painpoints.fx.OGAuroraSpec +import com.solidkey.painpoints.fx.OGCrackSpec +import com.solidkey.painpoints.fx.OGFireflySpec import com.solidkey.painpoints.fx.OGLightningSpec +import com.solidkey.painpoints.fx.OGRippleSpec +import com.solidkey.painpoints.fx.OGShockwaveSpec import com.solidkey.painpoints.fx.OGSlashSpec import com.solidkey.painpoints.fx.OGSpeedLinesSpec +import com.solidkey.painpoints.fx.OGWaveSpec import com.solidkey.painpoints.look.OGBlendMode import com.solidkey.painpoints.look.OGLookSpec +import com.solidkey.painpoints.motion.OGClothSpec import com.solidkey.painpoints.particle.OGParticleSpec import com.solidkey.painpoints.style.OGStyleSpec import kotlinx.serialization.json.Json @@ -131,6 +138,79 @@ object OGStages { blend: OGBlendMode = OGBlendMode.NORMAL, ): OGStageLayer = OGStageLayer(id = id, content = OGStageContent.SpeedLines(spec), startMs = startMs, endMs = endMs, blend = blend) + /** A procedural wave surface layer (calm, ocean, flood, lava). */ + fun wave( + id: String, + spec: OGWaveSpec, + startMs: Long = 0L, + endMs: Long? = null, + x: OGKeyframedFloat = OGKeyframedFloat.const(0f), + y: OGKeyframedFloat = OGKeyframedFloat.const(0f), + scale: OGKeyframedFloat = OGKeyframedFloat.const(1f), + opacity: OGKeyframedFloat = OGKeyframedFloat.const(1f), + blend: OGBlendMode = OGBlendMode.NORMAL, + ): OGStageLayer = OGStageLayer(id = id, content = OGStageContent.Wave(spec), startMs = startMs, endMs = endMs, x = x, y = y, scale = scale, opacity = opacity, blend = blend) + + /** An impact crack / fracture layer. */ + fun crack( + id: String, + spec: OGCrackSpec, + originX: Float = 0.5f, + originY: Float = 0.5f, + progress: OGKeyframedFloat = OGKeyframedFloat.const(1f), + startMs: Long = 0L, + endMs: Long? = null, + x: OGKeyframedFloat = OGKeyframedFloat.const(0f), + y: OGKeyframedFloat = OGKeyframedFloat.const(0f), + opacity: OGKeyframedFloat = OGKeyframedFloat.const(1f), + blend: OGBlendMode = OGBlendMode.NORMAL, + ): OGStageLayer = OGStageLayer(id = id, content = OGStageContent.Crack(spec, originX, originY, progress), startMs = startMs, endMs = endMs, x = x, y = y, opacity = opacity, blend = blend) + + /** An expanding impact shockwave ring layer. */ + fun shockwave( + id: String, + spec: OGShockwaveSpec, + originX: Float = 0.5f, + originY: Float = 0.5f, + progress: OGKeyframedFloat = OGKeyframedFloat.const(1f), + startMs: Long = 0L, + endMs: Long? = null, + x: OGKeyframedFloat = OGKeyframedFloat.const(0f), + y: OGKeyframedFloat = OGKeyframedFloat.const(0f), + opacity: OGKeyframedFloat = OGKeyframedFloat.const(1f), + blend: OGBlendMode = OGBlendMode.NORMAL, + ): OGStageLayer = OGStageLayer(id = id, content = OGStageContent.Shockwave(spec, originX, originY, progress), startMs = startMs, endMs = endMs, x = x, y = y, opacity = opacity, blend = blend) + + /** An aurora borealis curtain layer. */ + fun aurora( + id: String, + spec: OGAuroraSpec, + startMs: Long = 0L, + endMs: Long? = null, + opacity: OGKeyframedFloat = OGKeyframedFloat.const(1f), + blend: OGBlendMode = OGBlendMode.SCREEN, + ): OGStageLayer = OGStageLayer(id = id, content = OGStageContent.Aurora(spec), startMs = startMs, endMs = endMs, opacity = opacity, blend = blend) + + /** A raindrop ripple capillary ring layer. */ + fun ripple( + id: String, + spec: OGRippleSpec, + startMs: Long = 0L, + endMs: Long? = null, + opacity: OGKeyframedFloat = OGKeyframedFloat.const(1f), + blend: OGBlendMode = OGBlendMode.NORMAL, + ): OGStageLayer = OGStageLayer(id = id, content = OGStageContent.Ripple(spec), startMs = startMs, endMs = endMs, opacity = opacity, blend = blend) + + /** A synchronized Kuramoto firefly layer. */ + fun firefly( + id: String, + spec: OGFireflySpec, + startMs: Long = 0L, + endMs: Long? = null, + opacity: OGKeyframedFloat = OGKeyframedFloat.const(1f), + blend: OGBlendMode = OGBlendMode.PLUS, + ): OGStageLayer = OGStageLayer(id = id, content = OGStageContent.Firefly(spec), startMs = startMs, endMs = endMs, opacity = opacity, blend = blend) + /** * A **styled-vector-text** layer — a title / caption / lower-third, centred in the box (position it * with [x] / [y], a fraction of the box). [size] is the em height as a fraction of the stage height; @@ -163,6 +243,93 @@ object OGStages { onTap = onTap, ) + /** + * A **raster-cutout** layer — a bundled image shown as real drawn pixels (not a redrawn vector), + * optionally a frame-swap sprite ([frames] held in order on the local clock: blink / mouth / wave). + * The image fits within [widthFrac] × [heightFrac] of the box (aspect preserved); place and size it + * with [x] / [y] / [scale], and make it poke-reactive with [onTap]. The frame resources must be + * bundled by the host app (Android `res/drawable`, iOS bundle). + */ + fun image( + id: String, + frames: List, + widthFrac: Float = 1f, + heightFrac: Float = 1f, + startMs: Long = 0L, + endMs: Long? = null, + x: OGKeyframedFloat = OGKeyframedFloat.const(0f), + y: OGKeyframedFloat = OGKeyframedFloat.const(0f), + scale: OGKeyframedFloat = OGKeyframedFloat.const(1f), + rotationDeg: OGKeyframedFloat = OGKeyframedFloat.const(0f), + opacity: OGKeyframedFloat = OGKeyframedFloat.const(1f), + look: OGLookSpec? = null, + blend: OGBlendMode = OGBlendMode.NORMAL, + onTap: OGStageTrigger? = null, + ): OGStageLayer = OGStageLayer( + id = id, + content = OGStageContent.Image(frames, widthFrac, heightFrac), + startMs = startMs, endMs = endMs, + x = x, y = y, scale = scale, rotationDeg = rotationDeg, opacity = opacity, look = look, blend = blend, + onTap = onTap, + ) + + /** + * A **wind-blown cloth / hair strip** layer — a scarf, cape edge, banner or a lock of long hair that + * hangs and flutters on the breeze (the motion family's Verlet [OGClothSpec]: gravity + prevailing + * wind + turbulent gusts). The strip's head pins at ([anchorX], [anchorY]) in the box and streams out; + * [taper] narrows it to a point at the free end (hair / pennant). Pin it onto a character by placing + * this layer at the hairline / hem with [x] / [y]. Deterministic → preview and export match. + */ + fun cloth( + id: String, + cloth: OGClothSpec, + anchorX: Float = 0.5f, + anchorY: Float = 0.12f, + color: String = "#FFFFFF", + width: Float = 0.05f, + taper: Boolean = true, + tailX: Float = 0.5f, + tailY: Float = 0.12f, + startMs: Long = 0L, + endMs: Long? = null, + x: OGKeyframedFloat = OGKeyframedFloat.const(0f), + y: OGKeyframedFloat = OGKeyframedFloat.const(0f), + scale: OGKeyframedFloat = OGKeyframedFloat.const(1f), + rotationDeg: OGKeyframedFloat = OGKeyframedFloat.const(0f), + opacity: OGKeyframedFloat = OGKeyframedFloat.const(1f), + look: OGLookSpec? = null, + blend: OGBlendMode = OGBlendMode.NORMAL, + onTap: OGStageTrigger? = null, + ): OGStageLayer = OGStageLayer( + id = id, + content = OGStageContent.Cloth(cloth, anchorX, anchorY, tailX, tailY, color, width, taper), + startMs = startMs, endMs = endMs, + x = x, y = y, scale = scale, rotationDeg = rotationDeg, opacity = opacity, look = look, blend = blend, + onTap = onTap, + ) + + /** Shorthand for a single-frame still cutout (one bundled image, no frame-swap). */ + fun imageStill( + id: String, + resource: String, + widthFrac: Float = 1f, + heightFrac: Float = 1f, + startMs: Long = 0L, + endMs: Long? = null, + x: OGKeyframedFloat = OGKeyframedFloat.const(0f), + y: OGKeyframedFloat = OGKeyframedFloat.const(0f), + scale: OGKeyframedFloat = OGKeyframedFloat.const(1f), + rotationDeg: OGKeyframedFloat = OGKeyframedFloat.const(0f), + opacity: OGKeyframedFloat = OGKeyframedFloat.const(1f), + look: OGLookSpec? = null, + blend: OGBlendMode = OGBlendMode.NORMAL, + onTap: OGStageTrigger? = null, + ): OGStageLayer = image( + id = id, frames = listOf(OGImageFrame(resource)), widthFrac = widthFrac, heightFrac = heightFrac, + startMs = startMs, endMs = endMs, x = x, y = y, scale = scale, rotationDeg = rotationDeg, + opacity = opacity, look = look, blend = blend, onTap = onTap, + ) + // --- Interactivity shorthands -------------------------------------------------------------- /** Attach a tap [trigger] to any [layer] (returns a copy) — the generic path for layer kinds whose @@ -232,6 +399,12 @@ object OGStages { { "type":"slash", "slash": } // a water/flame/thunder sweep { "type":"lightning", "lightning": } // a forked bolt { "type":"speedlines", "speedLines": } // manga impact lines + { "type":"wave", "wave": } // procedural wave surface (calm/ocean/flood) + { "type":"crack", "crack": , "originX":0.5, "originY":0.5, "progress":1.0 } // ground fractures + { "type":"shockwave", "shockwave": , "originX":0.5, "originY":0.5, "progress":1.0 } // air/ground burst + { "type":"aurora", "aurora": } // polar light curtain + { "type":"ripple", "ripple": } // raindrop impact capillary ripples + { "type":"firefly", "firefly": } // synchronized Kuramoto fireflies { "type":"text", "text":"TITLE", "size":0.14, "color":"#RRGGBB", "bold":false, "italic":false, "family":null, "style": } // styled vector text The slash/lightning/speedlines specs are the KMPMedia action-FX packs, authored as their own diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/control/OGControlTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/control/OGControlTest.kt index 8900d96..7fc61ee 100644 --- a/library/src/commonTest/kotlin/com/solidkey/painpoints/control/OGControlTest.kt +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/control/OGControlTest.kt @@ -66,12 +66,18 @@ class OGControlTest { assertNotEquals(null, OGControls.preset("slime")!!.garnish) assertNull(OGControls.presets["plain"]!!.outline) assertEquals("top", OGControls.garnish("snow")!!.edge) + // Game presets + assertEquals(4.5f, OGControls.preset("game")!!.depth) + assertEquals("tactile", OGControls.preset("game")!!.pressEffect) + assertTrue(OGControls.preset("game-hero")!!.highlight) + assertEquals(24f, OGControls.preset("game-pill")!!.cornerRadius) } // ── press-effect mapping ───────────────────────────────────────────────────────────────── @Test fun pressEffect_mapsByName() { + assertTrue(ogControlPressEffect("tactile") is OGPressEffect.Tactile) assertTrue(ogControlPressEffect("scale") is OGPressEffect.Scale) assertTrue(ogControlPressEffect("DIM") is OGPressEffect.Dim) assertTrue(ogControlPressEffect(" brutalist ") is OGPressEffect.Brutalist) diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGCrackTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGCrackTest.kt new file mode 100644 index 0000000..dea3943 --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGCrackTest.kt @@ -0,0 +1,50 @@ +package com.solidkey.painpoints.fx + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OGCrackTest { + + @Test + fun presets_exist_and_are_case_insensitive() { + for (n in listOf("ground", "ice", "tile", "glass", "quake")) assertNotNull(OGCrack.preset(n), "preset $n") + assertNotNull(OGCrack.preset("GROUND")) + assertNotNull(OGCrack.preset("Ice")) + assertEquals(5, OGCrack.presets.size) + } + + @Test + fun codec_round_trips() { + val back = OGCrack.decodeSpec(OGCrack.encode(OGCrack.ICE)) + assertEquals(OGCrack.ICE, back) + } + + @Test + fun decode_bad_input_is_null_not_crash() { + assertEquals(null, OGCrack.decodeSpecOrNull("no json here at all")) + } + + @Test + fun arms_grow_from_origin_for_every_preset() { + for ((name, spec) in OGCrack.presets) { + val arms = crackArms(spec, 0.5f, 0.5f) + assertTrue(arms.size >= spec.arms, "$name: ${arms.size} arms, expected >= ${spec.arms}") + for (arm in arms) assertTrue(arm.points.size >= 2, "$name: arm with ${arm.points.size} points") + // Main arms start at the origin (sub-branches start partway along a parent, so check + // that at least spec.arms of them sit on the origin itself). + val atOrigin = arms.count { abs(it.points.first().x - 0.5f) < 1e-4f && abs(it.points.first().y - 0.5f) < 1e-4f } + assertTrue(atOrigin >= spec.arms, "$name: only $atOrigin arms anchored at the origin") + } + } + + @Test + fun arms_are_deterministic() { + val spec = OGCrack.GLASS + val a = crackArms(spec, 0.3f, 0.7f) + val b = crackArms(spec, 0.3f, 0.7f) + assertEquals(a, b) // same (spec, origin) → identical fracture (cross-platform guarantee) + } +} diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGEventsTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGEventsTest.kt new file mode 100644 index 0000000..c825333 --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGEventsTest.kt @@ -0,0 +1,182 @@ +package com.solidkey.painpoints.fx + +import com.solidkey.painpoints.stage.OGOfflineStageAuthor +import com.solidkey.painpoints.stage.OGStageContent +import com.solidkey.painpoints.stage.OGStages +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OGEventsTest { + + @Test + fun thunderStage_buildsPlayableLiveMovie() { + val stage = OGEvents.thunderStage(width = 1200, height = 800, durationMs = 4000L) + assertTrue(stage.isLive, "thunder stage must be live") + assertTrue(stage.layers.size >= 5, "thunder stage should have at least 5 layers") + + val contents = stage.layers.map { it.content } + assertTrue(contents.any { it is OGStageContent.Scene }, "has sky backdrop") + assertTrue(contents.any { it is OGStageContent.Solid }, "has ambient flash layer") + assertTrue(contents.any { it is OGStageContent.Lightning }, "has lightning bolt") + assertTrue(contents.any { it is OGStageContent.Particles }, "has particles (sparks/rain)") + + val text = contents.filterIsInstance().firstOrNull() + assertNotNull(text) + assertEquals("THUNDER", text.text) + + // Resolves cleanly across timeline + for (i in 0..8) { + val t = stage.durationMs * i / 8 + val resolved = stage.resolveAt(t) + assertTrue(resolved.isNotEmpty()) + } + + // Round trips through JSON codec + val encoded = OGStages.encode(stage) + val decoded = OGStages.decode(encoded) + assertEquals(stage.layers.size, decoded.layers.size) + } + + @Test + fun earthquakeStage_buildsPlayableLiveMovieWithTremor() { + val stage = OGEvents.earthquakeStage(width = 1200, height = 800, durationMs = 4000L) + assertTrue(stage.isLive, "earthquake stage must be live") + assertTrue(stage.layers.size >= 3, "earthquake stage should have at least 3 layers") + + // Backdrop carries camera tremor tracks + val backdrop = stage.layers.first { it.id == "earth_backdrop" } + assertTrue(backdrop.x.keyframes.isNotEmpty(), "tremor shake X track present") + assertTrue(backdrop.y.keyframes.isNotEmpty(), "tremor shake Y track present") + + val contents = stage.layers.map { it.content } + val text = contents.filterIsInstance().firstOrNull() + assertNotNull(text) + assertEquals("EARTHQUAKE", text.text) + + // Resolves cleanly across timeline + for (i in 0..8) { + val t = stage.durationMs * i / 8 + val resolved = stage.resolveAt(t) + assertTrue(resolved.isNotEmpty()) + } + + // Round trips through JSON codec + val encoded = OGStages.encode(stage) + val decoded = OGStages.decode(encoded) + assertEquals(stage.layers.size, decoded.layers.size) + } + + @Test + fun floodStage_buildsPlayableLiveMovieWithWaterCrests() { + val stage = OGEvents.floodStage(width = 1200, height = 800, durationMs = 4000L) + assertTrue(stage.isLive, "flood stage must be live") + assertTrue(stage.layers.size >= 4, "flood stage should have at least 4 layers") + + val contents = stage.layers.map { it.content } + assertTrue(contents.any { it is OGStageContent.Scene }, "has stormy horizon") + assertTrue(contents.any { it is OGStageContent.Particles }, "has spray/rain particles") + + val text = contents.filterIsInstance().firstOrNull() + assertNotNull(text) + assertEquals("FLOOD", text.text) + + // Resolves cleanly across timeline + for (i in 0..8) { + val t = stage.durationMs * i / 8 + val resolved = stage.resolveAt(t) + assertTrue(resolved.isNotEmpty()) + } + + // Round trips through JSON codec + val encoded = OGStages.encode(stage) + val decoded = OGStages.decode(encoded) + assertEquals(stage.layers.size, decoded.layers.size) + } + + @Test + fun offlineAuthor_routesDisasterEventsToCinematicStages() { + val author = OGOfflineStageAuthor() + + val quake = author.direct("an earthquake shattering the earth") + assertTrue(quake.layers.any { it.id == "earth_backdrop" }, "earthquake ask routes to earthquake stage") + + val flood = author.direct("a torrential flood raging over the land") + assertTrue(flood.layers.any { it.id == "horizon" }, "flood ask routes to flood stage") + + val thunder = author.direct("thunderstorm in the night sky") + assertTrue(thunder.layers.any { it.id == "sky_flash" }, "thunder ask routes to thunder stage with sky flash") + } + + @Test + fun newStageContent_roundTripsPolymorphically() { + val waveLayer = OGStages.wave("w", OGWave.FLOOD) + val crackLayer = OGStages.crack("c", OGCrack.GROUND) + val shockLayer = OGStages.shockwave("s", OGShockwave.GROUND) + val auroraLayer = OGStages.aurora("a", OGAurora.NORTHERN) + val rippleLayer = OGStages.ripple("r", OGRipple.RAIN) + val fireflyLayer = OGStages.firefly("f", OGFirefly.MEADOW) + + val stage = com.solidkey.painpoints.stage.OGStage.of( + waveLayer, crackLayer, shockLayer, auroraLayer, rippleLayer, fireflyLayer, + width = 800, height = 600, durationMs = 2000L, + ) + + val json = OGStages.encode(stage) + val decoded = OGStages.decode(json) + + assertEquals(6, decoded.layers.size) + assertTrue(decoded.layers[0].content is OGStageContent.Wave) + assertTrue(decoded.layers[1].content is OGStageContent.Crack) + assertTrue(decoded.layers[2].content is OGStageContent.Shockwave) + assertTrue(decoded.layers[3].content is OGStageContent.Aurora) + assertTrue(decoded.layers[4].content is OGStageContent.Ripple) + assertTrue(decoded.layers[5].content is OGStageContent.Firefly) + } + + @Test + fun eventStages_verifyDurationAndFpsDefaults() { + val tStage = OGEvents.thunderStage() + val eStage = OGEvents.earthquakeStage() + val fStage = OGEvents.floodStage() + + assertEquals(5000L, tStage.durationMs) + assertEquals(5000L, eStage.durationMs) + assertEquals(5000L, fStage.durationMs) + + assertTrue(tStage.fps in 24..60) + assertTrue(eStage.fps in 24..60) + assertTrue(fStage.fps in 24..60) + } + + @Test + fun gridTarget_mapsBoardIndicesToNormalizedCoordinates() { + val (cx, cy) = OGEvents.gridTarget(col = 2, row = 2, gridCols = 5, gridRows = 5) + assertEquals(0.5f, cx, 0.001f) + assertEquals(0.5f, cy, 0.001f) + + val (c0, r0) = OGEvents.gridTarget(col = 0, row = 0, gridCols = 5, gridRows = 5) + assertTrue(c0 < 0.25f) + assertTrue(r0 < 0.25f) + } + + @Test + fun thunderStage_hasSeismicTraumaTracksAndAirburst() { + val stage = OGEvents.thunderStage() + val sky = stage.layers.first { it.id == "sky" } + assertTrue(sky.x.keyframes.isNotEmpty(), "sky carries seismic tremor X") + assertTrue(sky.y.keyframes.isNotEmpty(), "sky carries seismic tremor Y") + + val shock = stage.layers.firstOrNull { it.id == "shockwave" } + assertNotNull(shock, "has ground airburst shockwave") + assertTrue(shock.content is OGStageContent.Shockwave) + } + + @Test + fun floodStage_hasTidalSwellTrack() { + val stage = OGEvents.floodStage() + val horizon = stage.layers.first { it.id == "horizon" } + assertTrue(horizon.y.keyframes.isNotEmpty(), "horizon carries tidal swell Y track") + } +} diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGFlameTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGFlameTest.kt new file mode 100644 index 0000000..fdbc72c --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGFlameTest.kt @@ -0,0 +1,99 @@ +package com.solidkey.painpoints.fx + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OGFlameTest { + + @Test + fun presets_exist_and_are_case_insensitive() { + for (n in listOf("candle", "campfire", "torch", "inferno")) assertNotNull(OGFlame.preset(n), "preset $n") + assertNotNull(OGFlame.preset("CANDLE")) + assertEquals(4, OGFlame.presets.size) + } + + @Test + fun codec_round_trips() { + val back = OGFlame.decodeSpec(OGFlame.encode(OGFlame.CAMPFIRE)) + assertEquals(OGFlame.CAMPFIRE, back) + } + + @Test + fun decode_tolerates_prose_and_partial_json() { + val s = OGFlame.decodeSpec("here you go: {\"name\":\"x\",\"coreColor\":\"#102030\",\"tongues\":5} ok?") + assertEquals("x", s.name) + assertEquals("#102030", s.coreColor) + assertEquals(5, s.tongues) + } + + @Test + fun decode_bad_input_is_null_not_crash() { + assertEquals(null, OGFlame.decodeSpecOrNull("no json here at all")) + } + + @Test + fun sanitized_clamps_out_of_range() { + val s = OGFlameSpec(tongues = 9999, height = 9f, width = -1f, sway = 9f, wind = 9f, octaves = 99, opacity = 5f, baseY = 9f).sanitized() + assertTrue(s.tongues in 1..60) + assertTrue(s.height <= 1.5f) + assertTrue(s.width >= 0.01f) + assertTrue(s.sway in 0f..0.5f) + assertTrue(s.wind in -1f..1f) + assertTrue(s.octaves in 1..8) + assertEquals(1f, s.opacity) + assertEquals(1f, s.baseY) + } + + @Test + fun tongues_are_deterministic_and_well_formed() { + val spec = OGFlame.CAMPFIRE.sanitized() + val a = flameTongues(spec, 1.5f) + val b = flameTongues(spec, 1.5f) + assertEquals(a, b) // same (spec, time) → identical fire (cross-platform guarantee) + assertEquals(spec.tongues, a.size) + for (t in a) { + assertTrue(t.spine.size >= 2, "each tongue needs a spine") + assertTrue(t.alpha in 0f..1f, "alpha: ${t.alpha}") + assertTrue(t.halfWidth > 0f, "halfWidth: ${t.halfWidth}") + // Base sits at baseY, tip is above it (smaller y, screen convention). + assertTrue(t.spine.last().y < t.spine.first().y, "tongue should rise upward") + } + } + + @Test + fun flame_flickers_over_time() { + val spec = OGFlame.CAMPFIRE.sanitized() + val t0 = flameTongues(spec, 0f) + val t1 = flameTongues(spec, 1f) + assertTrue( + t0.indices.any { t0[it].spine.last().y != t1[it].spine.last().y }, + "flame tips should move/flicker over time", + ) + } + + @Test + fun wind_bends_the_tips_downwind() { + val calm = flameTongues(OGFlame.CANDLE.copy(wind = 0f, sway = 0f).sanitized(), 0.5f) + val windy = flameTongues(OGFlame.CANDLE.copy(wind = 0.3f, sway = 0f).sanitized(), 0.5f) + // Base roots unchanged, tips pushed to the right (+x) by the wind lean. + assertTrue(abs(calm[0].spine.first().x - windy[0].spine.first().x) < 1e-4f, "base stays rooted") + assertTrue(windy[0].spine.last().x > calm[0].spine.last().x, "tip should lean downwind") + } + + @Test + fun flameTongues_multiTongueHasParabolicBowlRoots() { + // Multi-tongue flame roots should not form a dead-flat line across baseY; edge tongues should curve upward + val spec = OGFlameSpec(tongues = 9, height = 0.8f, baseY = 1.0f, seed = 42).sanitized() + val tongues = flameTongues(spec, 0f) + val centerBaseY = tongues[4].spine.first().y // center tongue + val edgeBaseY0 = tongues[0].spine.first().y // leftmost tongue + val edgeBaseY8 = tongues[8].spine.first().y // rightmost tongue + + // Screen coordinates: smaller y = higher up + assertTrue(edgeBaseY0 < centerBaseY, "left edge root should be elevated compared to center") + assertTrue(edgeBaseY8 < centerBaseY, "right edge root should be elevated compared to center") + } +} diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGFxPackTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGFxPackTest.kt index 294797d..a4baf7c 100644 --- a/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGFxPackTest.kt +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGFxPackTest.kt @@ -100,7 +100,7 @@ class OGFxPackTest { @Test fun elementalPresets_arePresent() { - for (n in listOf("petals", "embers", "droplets", "leaves")) assertNotNull(OGParticles.preset(n)) - assertEquals(10, OGParticles.presets.size) // 6 original + 4 elemental + for (n in listOf("petals", "embers", "droplets", "bubbles", "leaves")) assertNotNull(OGParticles.preset(n)) + assertEquals(13, OGParticles.presets.size) // 6 original + 5 elemental + BLIZZARD + SPARKLES } } diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGShockwaveTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGShockwaveTest.kt new file mode 100644 index 0000000..a57eafc --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGShockwaveTest.kt @@ -0,0 +1,34 @@ +package com.solidkey.painpoints.fx + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class OGShockwaveTest { + + @Test + fun presets_exist_and_are_case_insensitive() { + for (n in listOf("airburst", "ground", "splash", "electric")) assertNotNull(OGShockwave.preset(n), "preset $n") + assertNotNull(OGShockwave.preset("AIRBURST")) + assertNotNull(OGShockwave.preset("Ground")) + assertEquals(4, OGShockwave.presets.size) + } + + @Test + fun codec_round_trips() { + val back = OGShockwave.decodeSpec(OGShockwave.encode(OGShockwave.GROUND)) + assertEquals(OGShockwave.GROUND, back) + } + + @Test + fun decode_bad_input_is_null_not_crash() { + assertEquals(null, OGShockwave.decodeSpecOrNull("no json here at all")) + } + + @Test + fun sanitized_clamps_squash() { + assertEquals(0.1f, OGShockwaveSpec(squash = -3f).sanitized().squash) + assertEquals(0.1f, OGShockwaveSpec(squash = 0.05f).sanitized().squash) + assertEquals(1f, OGShockwaveSpec(squash = 2.5f).sanitized().squash) + } +} diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGWaveTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGWaveTest.kt new file mode 100644 index 0000000..e48f29c --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/fx/OGWaveTest.kt @@ -0,0 +1,55 @@ +package com.solidkey.painpoints.fx + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OGWaveTest { + + @Test + fun presets_exist_and_are_case_insensitive() { + for (n in listOf("calm", "ocean", "flood", "lava")) assertNotNull(OGWave.preset(n), "preset $n") + assertNotNull(OGWave.preset("OCEAN")) + assertEquals(4, OGWave.presets.size) + } + + @Test + fun codec_round_trips() { + val back = OGWave.decodeSpec(OGWave.encode(OGWave.OCEAN)) + assertEquals(OGWave.OCEAN, back) + } + + @Test + fun decode_tolerates_prose_and_partial_json() { + val s = OGWave.decodeSpec("sure, here: {\"name\":\"x\",\"deepColor\":\"#102030\",\"layers\":5} — enjoy") + assertEquals("x", s.name) + assertEquals("#102030", s.deepColor) + assertEquals(5, s.layers) + } + + @Test + fun decode_bad_input_is_null_not_crash() { + assertEquals(null, OGWave.decodeSpecOrNull("no json here at all")) + } + + @Test + fun bands_are_deterministic_well_formed_and_move() { + val spec = OGWave.OCEAN.sanitized() + val a = waveBands(spec, 1.5f) + val b = waveBands(spec, 1.5f) + assertEquals(a, b) // same (spec, time) → identical bands (cross-platform guarantee) + assertEquals(spec.layers, a.size) + for (band in a) { + assertTrue(band.crests.size >= 2, "crest samples: ${band.crests.size}") + assertTrue(band.fillAlpha in 0f..1f, "fillAlpha: ${band.fillAlpha}") + assertEquals(0f, band.crests.first().x) + assertEquals(1f, band.crests.last().x) + } + val later = waveBands(spec, 3.5f) + assertTrue( + a.indices.any { i -> a[i].crests.indices.any { j -> a[i].crests[j].y != later[i].crests[j].y } }, + "waves should move over time", + ) + } +} diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/gesture/OGButtonTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/gesture/OGButtonTest.kt index eb55652..5f389e6 100644 --- a/library/src/commonTest/kotlin/com/solidkey/painpoints/gesture/OGButtonTest.kt +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/gesture/OGButtonTest.kt @@ -31,6 +31,18 @@ class OGButtonTest { assertEquals(0f, e.transformAt(1f).translateFraction, eps) } + @Test + fun tactileEffect_drivesVerticalTranslationAndMicroScale() { + val e = OGPressEffect.Tactile(scale = 0.98f) + val t0 = e.transformAt(0f) + assertEquals(1f, t0.scale, eps) + assertEquals(0f, t0.translateYFraction, eps) + val t1 = e.transformAt(1f) + assertEquals(0.98f, t1.scale, eps) + assertEquals(1f, t1.translateYFraction, eps) + assertEquals(1f, t1.alpha, eps) + } + @Test fun brutalistEffect_drivesTranslationFractionOnly() { val e = OGPressEffect.Brutalist() diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/glass/OGGlassTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/glass/OGGlassTest.kt new file mode 100644 index 0000000..7d70e2c --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/glass/OGGlassTest.kt @@ -0,0 +1,52 @@ +package com.solidkey.painpoints.glass + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class OGGlassTest { + + @Test + fun presets_exist_and_are_case_insensitive() { + for (n in listOf("magnifier", "reader", "minifier", "amber", "rose")) assertNotNull(OGGlass.preset(n), "preset $n") + assertNotNull(OGGlass.preset("MAGNIFIER")) + assertEquals(5, OGGlass.presets.size) + } + + @Test + fun magnifier_magnifies_minifier_shrinks() { + assertTrue(OGGlass.MAGNIFIER.zoom > 1f, "magnifier should zoom in") + assertTrue(OGGlass.MINIFIER.zoom < 1f, "minifier should zoom out") + } + + @Test + fun codec_round_trips() { + val back = OGGlass.decodeSpec(OGGlass.encode(OGGlass.AMBER)) + assertEquals(OGGlass.AMBER, back) + } + + @Test + fun decode_tolerates_prose_and_partial_json() { + val s = OGGlass.decodeSpec("lens please: {\"name\":\"x\",\"zoom\":3.0,\"tint\":\"#102030\"} thanks") + assertEquals("x", s.name) + assertEquals(3f, s.zoom) + assertEquals("#102030", s.tint) + } + + @Test + fun decode_bad_input_is_null_not_crash() { + assertEquals(null, OGGlass.decodeSpecOrNull("no json here at all")) + } + + @Test + fun sanitized_clamps_out_of_range() { + val s = OGGlassSpec(zoom = 99f, radius = 9f, tintStrength = 5f, rimWidth = 9f, rimAlpha = 5f, highlight = 9f).sanitized() + assertTrue(s.zoom <= 6f) + assertTrue(s.radius in 0.03f..1f) + assertEquals(1f, s.tintStrength) + assertTrue(s.rimWidth in 0f..0.5f) + assertEquals(1f, s.rimAlpha) + assertEquals(1f, s.highlight) + } +} diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/motion/OGClothStripTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/motion/OGClothStripTest.kt index 8bf4dd3..2d4b05f 100644 --- a/library/src/commonTest/kotlin/com/solidkey/painpoints/motion/OGClothStripTest.kt +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/motion/OGClothStripTest.kt @@ -4,6 +4,7 @@ import kotlin.math.abs import kotlin.math.sqrt import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -125,4 +126,26 @@ class OGClothStripTest { assertTrue(abs(sampled.y(i) - manual.y(i)) < 1e-5f, "node $i y reproducible") } } + + @Test + fun materialLibrary_overlaysFeelButKeepsFormAndSceneWind() { + // Named materials exist and span light→heavy. + assertNotNull(OGCloths.material("cotton")) + assertNotNull(OGCloths.material("silk")) + assertNull(OGCloths.material("unobtanium")) + assertTrue(OGCloths.material("denim")!!.gravity > OGCloths.material("chiffon")!!.gravity, "denim is heavier than chiffon") + + // withMaterial overlays weight/stiffness/air-catch but preserves form + the scene's prevailing wind. + val base = OGCloths.preset("scarf")!!.copy(wind = -0.5f, nodes = 20, length = 0.6f, pinTail = true) + val silk = base.withMaterial("silk") + assertEquals(OGCloths.material("silk")!!.gravity, silk.gravity) + assertEquals(OGCloths.material("silk")!!.damping, silk.damping) + assertEquals(-0.5f, silk.wind, "prevailing scene wind is kept") + assertEquals(20, silk.nodes, "form (nodes) is kept") + assertEquals(0.6f, silk.length, "form (length) is kept") + assertTrue(silk.pinTail, "form (pinTail) is kept") + + // Unknown material → unchanged. + assertEquals(base, base.withMaterial("nope")) + } } diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/particle/OGParticleSystemTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/particle/OGParticleSystemTest.kt index 749b0d9..5c9a3aa 100644 --- a/library/src/commonTest/kotlin/com/solidkey/painpoints/particle/OGParticleSystemTest.kt +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/particle/OGParticleSystemTest.kt @@ -1,13 +1,39 @@ package com.solidkey.painpoints.particle +import kotlin.math.abs import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue /** Covers the pure particle simulation ([OGParticleSystem]), its emission/lifetime/physics and the codec. */ class OGParticleSystemTest { + @Test + fun wind_pushesParticlesSideways_whileNoWindLeavesXUntouched() { + // A single particle launched straight DOWN with no horizontal motion/gravity. + fun spec(wind: Float, amp: Float) = OGParticleSpec( + burst = 1, emissionRate = 0f, maxParticles = 1, lifetimeMs = 100_000f, lifetimeJitter = 0f, + x = 0.5f, y = 0.1f, spawnRadius = 0f, angleDeg = 90f, spreadDeg = 0f, speed = 0.1f, speedJitter = 0f, + gravityX = 0f, gravityY = 0f, drag = 0f, wind = wind, windAmplitude = amp, seed = 1, + ) + // No wind → x never moves (byte-identical to the old integrator). + val calm = spec(0f, 0f).toSystem() + repeat(60) { calm.update(16f) } + assertEquals(0.5f, calm.x[0], 1e-4f) + + // Steady rightward wind → x drifts right. + val windy = spec(0.5f, 0f).toSystem() + repeat(60) { windy.update(16f) } + assertTrue(windy.x[0] > 0.5f, "steady wind should push x right, was ${windy.x[0]}") + + // Turbulent gusts alone (no prevailing wind) → x is perturbed off-centre. + val gusty = spec(0f, 0.6f).toSystem() + repeat(60) { gusty.update(16f) } + assertTrue(abs(gusty.x[0] - 0.5f) > 1e-3f, "gusts should perturb x, was ${gusty.x[0]}") + } + @Test fun burst_spawnsExactlyBurstCount_onFirstUpdate() { val sys = OGParticleSpec(burst = 10, emissionRate = 0f, maxParticles = 100, lifetimeMs = 10_000f).toSystem() @@ -105,6 +131,15 @@ class OGParticleSystemTest { assertEquals(5, sys.count) } + @Test + fun bubblesPreset_existsAndRisesBuoyantly() { + val spec = OGParticles.preset("bubbles") + assertNotNull(spec) + assertEquals(OGParticles.BUBBLES, OGParticles.preset("BUBBLES")) // case-insensitive + assertTrue(spec.gravityY < 0f, "bubbles are buoyant (negative gravity)") + assertEquals(13, OGParticles.presets.size) + } + @Test fun confettiPreset_runsAndFillsToItsBurst() { val sys = OGParticles.CONFETTI.toSystem() diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/puppet/OGPuppetTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/puppet/OGPuppetTest.kt new file mode 100644 index 0000000..d921131 --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/puppet/OGPuppetTest.kt @@ -0,0 +1,95 @@ +package com.solidkey.painpoints.puppet + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Covers the puppet codec ([OGPuppet]) and the pure, deterministic pose math ([OGPuppetRig]). */ +class OGPuppetTest { + + private val sample = OGPuppetSpec( + base = "chipmunk_body", + aspect = 0.7f, + grounded = true, + parts = listOf( + OGPuppetPart("chipmunk_arm_r", pivotX = 0.25f, pivotY = 0.48f, swingDeg = 4f, speed = 0.7f, phase = 2.6f), + OGPuppetPart("chipmunk_head", pivotX = 0.56f, pivotY = 0.42f, swingDeg = 2f, head = true), + OGPuppetPart("chipmunk_eye_l", pivotX = 0.73f, pivotY = 0.22f, gaze = 0.028f), + ), + ) + + @Test + fun codec_roundTrips() { + val json = OGPuppet.encode(sample) + val back = OGPuppet.decode(json) + assertEquals(sample, back) + } + + @Test + fun decode_toleratesProseAndFences() { + val wrapped = "sure! here it is:\n```json\n" + OGPuppet.encode(sample) + "\n```\n" + assertEquals(sample, OGPuppet.decode(wrapped)) + } + + @Test + fun decodeOrNull_isNullOnGarbage() { + assertNull(OGPuppet.decodeOrNull("not json at all")) + } + + @Test + fun headPart_isTheOneFlaggedHead() { + assertEquals("chipmunk_head", sample.headPart?.res) + assertNull(sample.copy(parts = sample.parts.filter { !it.head }).headPart) + } + + @Test + fun effectiveLook_usesHostValueWhenGiven_elseIdleGlance() { + assertEquals(0.5f, OGPuppetRig.effectiveLook(0.5f, 1.23f)) + assertEquals(1f, OGPuppetRig.effectiveLook(2f, 0f)) // clamped + // null → a bounded idle glance within ±0.6 + assertTrue(abs(OGPuppetRig.effectiveLook(null, 3.3f)) <= 0.6001f) + } + + @Test + fun groundedFigure_neverFloats_andBendsTowardLook() { + assertEquals(0f, OGPuppetRig.figureFloat(sample, 2f)) // grounded: no vertical hover + // Bending more toward a hard-right look than at centre. + val atCentre = OGPuppetRig.figureRotation(sample, 0f, 0f) + val atRight = OGPuppetRig.figureRotation(sample, 0f, 1f) + assertTrue(atRight > atCentre) + } + + @Test + fun flyingFigure_floatsAndStaysBounded() { + val flyer = sample.copy(grounded = false, hoverAmp = 0.05f) + var maxAbs = 0f + var i = 0 + while (i < 400) { maxAbs = maxOf(maxAbs, abs(OGPuppetRig.figureFloat(flyer, i * 0.05f))); i++ } + assertTrue(maxAbs > 0f && maxAbs <= 0.05001f) + } + + @Test + fun headTurns_butEyesOnlyGaze() { + val head = sample.parts.first { it.head } + val eye = sample.parts.first { it.gaze > 0f } + // Head mirror-flips with the look sign; a plain part stays width 1. + assertTrue(OGPuppetRig.partScaleX(sample, head, 1f) > 0f) + assertTrue(OGPuppetRig.partScaleX(sample, head, -1f) < 0f) + assertEquals(1f, OGPuppetRig.partScaleX(sample, eye, -1f)) + // A symmetric face (headMirror = false) never flips — it stays width 1 and only tilts. + assertEquals(1f, OGPuppetRig.partScaleX(sample.copy(headMirror = false), head, -1f)) + // The eye slides toward the look; a hard-right look pushes it right by +gaze. + assertEquals(eye.gaze, OGPuppetRig.partOffsetX(eye, 1f)) + assertEquals(0f, OGPuppetRig.partOffsetX(eye, 0f)) + } + + @Test + fun partAngle_headAddsLookTurn_overDampedIdle() { + val head = sample.parts.first { it.head } + // At t=0 the idle term for the head is swingDeg*0.25*sin(phase=0)=0, so the angle is purely the look-turn. + val a = OGPuppetRig.partAngle(sample, head, 0f, 1f) + assertEquals(sample.lookTurnDeg, a, 1e-4f) + } +} diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/shape/OGSnowflakeTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/shape/OGSnowflakeTest.kt new file mode 100644 index 0000000..6e57e55 --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/shape/OGSnowflakeTest.kt @@ -0,0 +1,56 @@ +package com.solidkey.painpoints.shape + +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class OGSnowflakeTest { + + @Test + fun deterministic_sameSeedSameCrystal() { + val a = OGSnowflake.generate(seed = 42) + val b = OGSnowflake.generate(seed = 42) + assertEquals(a, b, "same seed must reproduce the same crystal exactly") + } + + @Test + fun differentSeeds_differByDefault() { + assertTrue(OGSnowflake.generate(1) != OGSnowflake.generate(2), "different seeds should differ") + } + + @Test + fun hasRadialSymmetry_segmentCountIsAMultipleOfArms() { + val arms = 6 + val segs = OGSnowflake.generate(seed = 7, arms = arms, branches = 3) + assertTrue(segs.isNotEmpty()) + assertEquals(0, segs.size % arms, "every arm contributes the same segment count") + } + + @Test + fun depthZero_isPlainProngs_oneSpinePlusTwoTwigsPerBranch() { + val arms = 6 + val branches = 3 + val segs = OGSnowflake.generate(seed = 7, arms = arms, branches = branches, depth = 0) + // depth 0 = the old look: 1 spine + 2 twigs per branch, times the arm count. + assertEquals((1 + branches * 2) * arms, segs.size) + } + + @Test + fun deeperFractal_addsMoreSegments() { + val plain = OGSnowflake.generate(seed = 7, branches = 3, depth = 0).size + val d1 = OGSnowflake.generate(seed = 7, branches = 3, depth = 1).size + val d2 = OGSnowflake.generate(seed = 7, branches = 3, depth = 2).size + assertTrue(d1 > plain, "one level of recursion should add twigs ($d1 vs $plain)") + assertTrue(d2 > d1, "two levels should add still more ($d2 vs $d1)") + } + + @Test + fun staysWithinUnitCircle() { + val segs = OGSnowflake.generate(seed = 99, arms = 6, branches = 4, depth = 3) + for (s in segs) for (p in listOf(s.a, s.b)) { + val r = sqrt(p.x * p.x + p.y * p.y) + assertTrue(r <= 1.75f, "point radius $r should stay within the unit-ish circle") + } + } +} diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGCutoutRigTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGCutoutRigTest.kt new file mode 100644 index 0000000..77e4947 --- /dev/null +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGCutoutRigTest.kt @@ -0,0 +1,104 @@ +package com.solidkey.painpoints.stage + +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** Tests for the cut-out puppet rig → [OGStage] bake (the nexum "cutoutRig" capability + its offline floor). */ +class OGCutoutRigTest { + + private fun superxRig() = OGCutoutRig( + width = 1024, height = 572, durationMs = 2600L, fps = 60, + layers = listOf( + OGCutoutLayer(id = "fig", role = "base"), + OGCutoutLayer(id = "arm_r", role = "mover", pivotX = 0.43f, pivotY = 0.26f, swingDeg = 12f), + OGCutoutLayer(id = "leg_l", role = "mover", pivotX = 0.47f, pivotY = 0.70f, swingDeg = 8f), + ), + ) + + @Test + fun rig_round_trips_through_codec() { + val rig = superxRig() + val decoded = OGCutoutRigs.decode(OGCutoutRigs.encode(rig)) + assertEquals(rig, decoded) + } + + @Test + fun codec_is_lenient_about_prose_and_role_case() { + // extractJson strips the prose + fences; isBase ignores the role's letter-case. + val wrapped = "Here is the rig:\n```json\n" + + "{\"width\":100,\"height\":100,\"layers\":[{\"id\":\"b\",\"role\":\"BASE\"}]}\n```" + val rig = OGCutoutRigs.decodeOrNull(wrapped) + assertNotNull(rig) + assertEquals(1, rig.layers.size) + assertTrue(rig.layers[0].isBase) + } + + @Test + fun bake_produces_a_layer_per_part_with_matching_canvas() { + val stage = OGOfflineCutoutRigAuthor().bake(superxRig()) + assertEquals(3, stage.layers.size) + assertEquals(1024, stage.width) + assertEquals(572, stage.height) + assertEquals(2600L, stage.durationMs) + assertEquals(listOf("fig", "arm_r", "leg_l"), stage.layers.map { it.id }) + } + + @Test + fun base_stays_still_and_movers_swing_about_their_seated_joint() { + val stage = OGOfflineCutoutRigAuthor().bake(superxRig()) + val base = stage.layers.first { it.id == "fig" } + val arm = stage.layers.first { it.id == "arm_r" } + + // Base: no motion, seated at the centre (full-bleed). + assertTrue(base.rotationDeg.keyframes.isEmpty(), "base must not rotate") + assertEquals(0f, base.x.valueAt(0L)) + assertEquals(0f, base.y.valueAt(0L)) + + // Mover: the centred joint is translated to (pivot − 0.5), and it rotates. + assertEquals(0.43f - 0.5f, arm.x.valueAt(0L), 1e-4f) + assertEquals(0.26f - 0.5f, arm.y.valueAt(0L), 1e-4f) + assertTrue(arm.rotationDeg.keyframes.isNotEmpty(), "a mover must swing") + // rest at the loop ends, off-rest somewhere in the middle + assertEquals(0f, arm.rotationDeg.valueAt(0L), 1e-3f) + assertEquals(0f, arm.rotationDeg.valueAt(2600L), 1e-3f) + val mid = (0L..2600L step 130L).maxOf { abs(arm.rotationDeg.valueAt(it)) } + assertTrue(mid > 1f, "the arm should visibly swing (peak was $mid deg)") + } + + @Test + fun baked_stage_is_live_and_movers_are_tappable() { + val stage = OGOfflineCutoutRigAuthor().bake(superxRig()) + assertTrue(stage.isLive, "a puppet with swinging limbs must be live") + assertNotNull(stage.layers.first { it.id == "arm_r" }.onTap, "movers should be pokeable") + } + + @Test + fun capability_prompt_carries_the_rig_and_decodes_a_stage() { + val rig = superxRig() + assertEquals("cutoutRig", OGCutoutRigCapability.id) + val prompt = OGCutoutRigCapability.promptFor(rig) + assertTrue(prompt.contains("arm_r"), "prompt should embed the rig") + // a real OGStage reply decodes… + val baked = OGOfflineCutoutRigAuthor().bake(rig) + val reply = "Sure, here you go:\n" + OGStages.encode(baked) + val decoded = OGCutoutRigCapability.decode(reply) + assertNotNull(decoded) + assertEquals(baked.layers.size, decoded.layers.size) + } + + @Test + fun resilient_helper_prefers_agent_then_floor() { + val rig = superxRig() + // agent gives junk → floor bakes + val viaFloor = bakeCutoutRig(rig, agentReply = "sorry, no idea") + assertEquals(3, viaFloor.layers.size) + // agent gives a valid stage → used as-is + val agentStage = OGStage.of(OGStages.solid("bg", "#000000"), width = 10, height = 10) + val viaAgent = bakeCutoutRig(rig, agentReply = OGStages.encode(agentStage)) + assertEquals(1, viaAgent.layers.size) + assertEquals(10, viaAgent.width) + } +} diff --git a/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGStageTest.kt b/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGStageTest.kt index 24cb91e..9db5352 100644 --- a/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGStageTest.kt +++ b/library/src/commonTest/kotlin/com/solidkey/painpoints/stage/OGStageTest.kt @@ -12,6 +12,7 @@ import com.solidkey.painpoints.fx.OGSlashSpec import com.solidkey.painpoints.fx.OGSpeedLinesSpec import com.solidkey.painpoints.look.OGBlendMode import com.solidkey.painpoints.look.OGLookSpec +import com.solidkey.painpoints.motion.OGCloths import com.solidkey.painpoints.particle.OGParticleSpec import com.solidkey.painpoints.style.OGStyleOp import com.solidkey.painpoints.style.OGStyleSpec @@ -62,6 +63,25 @@ class OGStageTest { assertEquals(stage, back, "a stage should survive encode → decode unchanged") } + @Test + fun clothLayerRoundTripsAndIsLive() { + val stage = OGStage.of( + OGStages.solid("bg", "#102030"), + OGStages.cloth( + "hair", + cloth = OGCloths.preset("hair")!!.copy(wind = 0.4f, windAmplitude = 0.5f), + anchorX = 0.5f, anchorY = 0.1f, color = "#6D4C41", width = 0.05f, taper = true, + ), + width = 100, height = 200, durationMs = 2000L, + ) + val back = OGStages.decode(OGStages.encode(stage)) + assertEquals(stage, back, "a cloth layer should survive encode → decode unchanged") + val cloth = back.layers[1].content + assertTrue(cloth is OGStageContent.Cloth && cloth.cloth.wind == 0.4f) + // A cloth strip always flutters → the stage reports itself live. + assertTrue(back.isLive, "a stage with a cloth layer is live") + } + @Test fun decodeToleratesFencesAndRejectsGarbage() { assertNull(OGStages.decodeOrNull("this is not json")) @@ -233,6 +253,60 @@ class OGStageTest { assertTrue(stage.isLive, "a mix with fx + particles + scene motion is live") } + @Test + fun imageCutoutRoundTripsAndFrameSwapDrivesLiveness() { + // A tappable, multi-frame raster cutout (a blink) — the puppet primitive. + val blink = listOf( + OGImageFrame("head_open", holdMs = 3000L), + OGImageFrame("head_half", holdMs = 40L), + OGImageFrame("head_shut", holdMs = 90L), + OGImageFrame("head_half", holdMs = 40L), + ) + val stage = OGStage.of( + OGStages.image("head", blink, widthFrac = 0.45f, heightFrac = 0.5f, + onTap = OGStageTrigger(OGTapAction.BOUNCE)), + ) + val back = OGStages.decode(OGStages.encode(stage)) + assertEquals(stage, back, "an image cutout (frames + fit box + onTap) survives JSON") + val c = back.layers.first().content + assertTrue(c is OGStageContent.Image) + assertEquals(4, c.frames.size) + assertEquals("head_open", c.frames.first().resource) + assertTrue(c.isAnimated) + assertEquals(0.45f, c.widthFrac, 1e-4f) + assertNotNull(back.layers.first().onTap) + + // A frame-swap sprite makes the stage live; a single still frame does not (unless transformed). + assertTrue(stage.isLive, "a multi-frame cutout animates by frame-swap") + assertTrue(!OGStage.of(OGStages.imageStill("still", "head_open")).isLive, "a single-frame cutout is static") + assertTrue( + OGStage.of(OGStages.imageStill("still", "head_open", opacity = OGStages.track(0L to 0f, 500L to 1f))).isLive, + "...but a fade-in transform still makes it live", + ) + } + + @Test + fun imageFrameIndexWalksHoldsAndLoops() { + val c = OGStageContent.Image( + frames = listOf( + OGImageFrame("a", holdMs = 100L), + OGImageFrame("b", holdMs = 50L), + OGImageFrame("c", holdMs = 50L), + ), + ) + assertEquals(0, c.frameIndexAt(0L)) + assertEquals(0, c.frameIndexAt(99L)) + assertEquals(1, c.frameIndexAt(100L)) + assertEquals(1, c.frameIndexAt(149L)) + assertEquals(2, c.frameIndexAt(150L)) + assertEquals(2, c.frameIndexAt(199L)) + // total span = 200ms, so it loops: t=200 is back to frame 0, t=300 back to frame 1. + assertEquals(0, c.frameIndexAt(200L)) + assertEquals(1, c.frameIndexAt(300L)) + // A single frame (or none) always resolves to index 0. + assertEquals(0, OGStageContent.Image(listOf(OGImageFrame("only"))).frameIndexAt(9999L)) + } + @Test fun degenerateStagesResolveSafely() { // An empty stage resolves to nothing without throwing. diff --git a/tools/layer_cutter.html b/tools/layer_cutter.html new file mode 100644 index 0000000..8d6251f --- /dev/null +++ b/tools/layer_cutter.html @@ -0,0 +1,2674 @@ + + + + + +✂️ KMPMedia Layer Cutter + + + +
+

✂️ Layer Cutter

+ — no image — + + + + + + + + +
+ +
+
+ +
+
+
🖼️
+
Open an image
+
Tap to pick from Photos or Files (any attachment)
· or paste a copied image · or drag one in ·
+ +
+
+
+ + +
+ +
+ + + + diff --git a/tools/rig_preview.html b/tools/rig_preview.html new file mode 100644 index 0000000..ed165ef --- /dev/null +++ b/tools/rig_preview.html @@ -0,0 +1,78 @@ + + + + + +▶ Rig Preview + + + + +
loading…
+ + +