SKILL.md

  1---
  2name: charm-harmonica
  3description: "Physics-based animation for Go TUIs - damped spring oscillator and projectile motion. Use when adding spring animations, physics-based motion, or smooth transitions to Go terminal apps. No Ease function exists in this library."
  4---
  5
  6# charm-harmonica
  7
  8Physics-based animation primitives: spring (damped harmonic oscillator) and projectile motion.
  9
 10## Quick Start
 11
 12```go
 13import "github.com/charmbracelet/harmonica"
 14
 15// Create spring once - expensive coefficients computed here
 16spring := harmonica.NewSpring(harmonica.FPS(60), 6.0, 0.5)
 17
 18// Per-frame state - YOU own these
 19var pos, vel float64
 20
 21// Each frame:
 22pos, vel = spring.Update(pos, vel, targetPos)
 23```
 24
 25## Core API
 26
 27### FPS(n int) float64
 28
 29Converts a frame rate to a delta time in seconds. Pass directly to `NewSpring` or `NewProjectile`.
 30
 31```go
 32dt := harmonica.FPS(60) // 0.01666...
 33```
 34
 35Use the engine's actual delta time instead when available (e.g. real elapsed time between frames).
 36
 37### NewSpring(deltaTime, angularFrequency, dampingRatio float64) Spring
 38
 39Pre-computes spring coefficients. Call once, reuse across frames.
 40
 41| Parameter | Effect |
 42|-----------|--------|
 43| `deltaTime` | Frame duration in seconds - use `FPS(n)` |
 44| `angularFrequency` | Speed of motion. Typical range: 1-20 |
 45| `dampingRatio` | Oscillation behavior |
 46
 47**Damping ratio guide:**
 48- `< 1.0` - under-damped, overshoots and oscillates
 49- `= 1.0` - critically damped, fastest without overshoot
 50- `> 1.0` - over-damped, slow and sluggish
 51
 52Practical starting points: frequency `6.0`, damping `0.5` (smooth). For bouncy: frequency `8.0`, damping `0.15`. For snappy: frequency `12.0`, damping `1.0`.
 53
 54### Spring.Update(pos, vel, target float64) (newPos, newVel float64)
 55
 56Advances one frame. Returns new position and velocity - always capture both.
 57
 58```go
 59// One spring can drive multiple independent axes
 60x, xVel = spring.Update(x, xVel, targetX)
 61y, yVel = spring.Update(y, yVel, targetY)
 62radius, radiusVel = spring.Update(radius, radiusVel, targetRadius)
 63```
 64
 65### NewProjectile(deltaTime float64, pos Point, vel, acc Vector) *Projectile
 66
 67Kinematic projectile - no spring, just position + velocity + acceleration per frame.
 68
 69```go
 70p := harmonica.NewProjectile(
 71    harmonica.FPS(60),
 72    harmonica.Point{X: 0, Y: 0, Z: 0},
 73    harmonica.Vector{X: 5, Y: 0, Z: 0},   // initial velocity
 74    harmonica.TerminalGravity,              // acceleration: {0, 9.81, 0}
 75)
 76
 77// Each frame:
 78pos := p.Update() // returns harmonica.Point
 79```
 80
 81**Gravity constants:**
 82- `harmonica.Gravity` - `{0, -9.81, 0}` - origin bottom-left
 83- `harmonica.TerminalGravity` - `{0, 9.81, 0}` - origin top-left (standard TUI)
 84
 85**Projectile accessors:** `p.Position()`, `p.Velocity()`, `p.Acceleration()`
 86
 87### No Ease Function
 88
 89harmonica does not have an Ease function. It has Spring and Projectile only.
 90
 91## bubbletea Integration Pattern
 92
 93The canonical pattern uses a `frameMsg` sentinel type and `tea.Tick` to drive the loop.
 94
 95```go
 96const fps = 60
 97
 98type frameMsg time.Time
 99
100// Schedules the next frame tick
101func animate() tea.Cmd {
102    return tea.Tick(time.Second/fps, func(t time.Time) tea.Msg {
103        return frameMsg(t)
104    })
105}
106
107type model struct {
108    x      float64
109    xVel   float64
110    spring harmonica.Spring
111}
112
113func (m model) Init() tea.Cmd {
114    return animate() // kick off the loop
115}
116
117func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118    switch msg.(type) {
119    case frameMsg:
120        const target = 60.0
121        m.x, m.xVel = m.spring.Update(m.x, m.xVel, target)
122
123        // Stop ticking when close enough
124        if math.Abs(m.x-target) < 0.01 {
125            return m, nil
126        }
127
128        return m, animate() // schedule next frame
129    }
130    return m, nil
131}
132
133func main() {
134    m := model{
135        spring: harmonica.NewSpring(harmonica.FPS(fps), 7.0, 0.15),
136    }
137    tea.NewProgram(m).Run()
138}
139```
140
141### Changing spring parameters mid-animation
142
143When frequency or damping changes, call `NewSpring` again with the same `deltaTime`. The current `pos` and `vel` carry over - do not reset them.
144
145```go
146// User changed settings - recompute spring, keep state
147spring = harmonica.NewSpring(harmonica.FPS(fps), newFreq, newDamp)
148// x, xVel unchanged - animation continues smoothly from current state
149```
150
151### Stopping the animation loop
152
153Return `nil` (no command) instead of `animate()` to stop. Resume by returning `animate()` again on the next relevant message.
154
155## Common Mistakes
156
157**Forgetting to capture velocity.** `spring.Update` returns two values. Discarding the velocity breaks the simulation on the next frame.
158```go
159// wrong
160pos, _ = spring.Update(pos, vel, target)
161
162// right
163pos, vel = spring.Update(pos, vel, target)
164```
165
166**Creating Spring inside the update loop.** `NewSpring` is expensive - it computes trig/exp coefficients. Create it once in `main` or `Init`, store it on the model.
167
168**Using `Gravity` instead of `TerminalGravity` in TUIs.** TUI coordinate systems have Y increasing downward. Use `TerminalGravity` (`{0, 9.81, 0}`) so things fall down the screen, not up.
169
170**Not passing real delta time in non-fixed-FPS contexts.** In game loops with variable frame time, pass the actual `time.Since(last).Seconds()` to `NewSpring` each frame instead of `FPS(60)`. Recreating the spring with the real dt each frame is correct and expected.
171
172**Calling `animate()` unconditionally.** Always check if the animation has converged before scheduling the next frame, or it runs forever at 60fps.
173
174## Checklist
175
176- [ ] `NewSpring` called once, stored on model struct
177- [ ] Both return values from `Update` captured (`pos, vel = ...`)
178- [ ] `animate()` returns `nil` when animation is done (convergence check)
179- [ ] Using `TerminalGravity` for TUI projectiles (Y-down coordinate space)
180- [ ] `frameMsg` type defined as `type frameMsg time.Time`
181- [ ] Spring recomputed (not state reset) when parameters change at runtime