Skip to main content

git_koi/
motion.rs

1//! Swimming over time: the pose for each frame of a behavior's seamless loop.
2
3use std::f64::consts::{PI, TAU};
4
5use crate::behavior::Behavior;
6
7/// Frames per second of every behavior's loop; GIF frame delays derive from it.
8pub const FRAMES_PER_SECOND: u32 = 15;
9
10const TAIL_BEND_PER_WAVE_STRENGTH: f64 = 0.45;
11const TAIL_BEND_LAG_RADIANS: f64 = 1.7;
12const TAIL_SPLIT_RADIANS: f64 = 0.1;
13const TAIL_SPLIT_LAG_RADIANS: f64 = 2.6;
14/// A side fin's tip points where its root pointed this many frames earlier.
15const SIDE_FIN_TRAIL_FRAMES: u32 = 3;
16const PELVIC_SHARE_OF_SIDE_FIN_OPENING: f64 = 0.5;
17const WHISKER_SWAY_RADIANS: f64 = 0.15;
18const WHISKER_SWAY_LAG_RADIANS: f64 = 0.6;
19
20/// Rest and surface feeding share this loop.
21const REST_FRAME_COUNT: u32 = 90;
22const REST_DEPTH: f64 = 0.22;
23/// Feeding ripples and pellets repeat this many times per loop.
24const FEEDING_CYCLES_PER_LOOP: f64 = 3.0;
25/// Cruising and darting breathe this many times per loop, opening this far.
26const BREATHS_PER_LOOP: f64 = 5.0;
27const BREATH_OPENING: f64 = 0.7;
28/// Resting and feeding gulp this many times per loop, opening fully.
29const GULPS_PER_LOOP: f64 = 3.0;
30const DEEP_REST_FRAME_COUNT: u32 = 120;
31const DEEP_REST_DEPTH: f64 = 0.5;
32
33/// The numbers behind one kind of lap swimming. Each loop holds whole numbers of
34/// laps, strokes, waves, and sculls, so it closes seamlessly.
35struct LapStyle {
36    loop_frame_count: u32,
37    lap_count: f64,
38    stroke_frame_count: f64,
39    /// Share of each stroke cycle spent beating; the rest is a glide.
40    stroke_share: f64,
41    /// Lap speed peaks this many frames after the stroke.
42    speed_lag_frames: f64,
43    glide_speed: f64,
44    stroke_speed_gain: f64,
45    wave_frame_count: f64,
46    glide_wave_strength: f64,
47    stroke_wave_gain: f64,
48    glide_tail_spread_radians: f64,
49    stroke_tail_spread_radians: f64,
50    side_fin_strokes: SideFinStrokes,
51    /// On the clockwise lap the right fin is inside the turn: it opens this much more
52    /// and the left fin this much less.
53    inside_flare_radians: f64,
54    scull_swing_radians: f64,
55    scull_frame_count: f64,
56    scull_phase_radians: f64,
57}
58
59/// How both side fins move with each stroke.
60enum SideFinStrokes {
61    /// Close during the stroke and open to brake through the glide.
62    Brake {
63        stroke_opening_radians: f64,
64        brake_opening_radians: f64,
65    },
66    /// Tuck against the body during the burst, then spread and relax through the
67    /// glide; both movements start and end at rest, so nothing snaps.
68    TuckAndSpread {
69        tuck_radians: f64,
70        spread_radians: f64,
71    },
72}
73
74/// One slow lap in eight seconds: two strokes, fifteen waves, and six sculls.
75const CRUISE_STYLE: LapStyle = LapStyle {
76    loop_frame_count: 120,
77    lap_count: 1.0,
78    stroke_frame_count: 60.0,
79    stroke_share: 0.55,
80    speed_lag_frames: 6.0,
81    glide_speed: 0.75,
82    stroke_speed_gain: 0.5,
83    wave_frame_count: 8.0,
84    glide_wave_strength: 0.3,
85    stroke_wave_gain: 0.9,
86    glide_tail_spread_radians: 0.05,
87    stroke_tail_spread_radians: 0.12,
88    side_fin_strokes: SideFinStrokes::Brake {
89        stroke_opening_radians: 0.08,
90        brake_opening_radians: 0.3,
91    },
92    inside_flare_radians: 0.12,
93    scull_swing_radians: 0.1,
94    scull_frame_count: 20.0,
95    scull_phase_radians: 1.1,
96};
97
98/// Two fast laps in eight seconds: four bursts, twenty waves, and eight sculls.
99const DART_STYLE: LapStyle = LapStyle {
100    loop_frame_count: 120,
101    lap_count: 2.0,
102    stroke_frame_count: 30.0,
103    stroke_share: 0.35,
104    speed_lag_frames: 3.0,
105    glide_speed: 0.35,
106    stroke_speed_gain: 2.2,
107    wave_frame_count: 6.0,
108    glide_wave_strength: 0.25,
109    stroke_wave_gain: 1.4,
110    glide_tail_spread_radians: 0.04,
111    stroke_tail_spread_radians: 0.16,
112    side_fin_strokes: SideFinStrokes::TuckAndSpread {
113        tuck_radians: 0.12,
114        spread_radians: 0.4,
115    },
116    inside_flare_radians: 0.1,
117    scull_swing_radians: 0.08,
118    scull_frame_count: 15.0,
119    scull_phase_radians: 0.0,
120};
121
122/// Where the koi swims in a frame.
123#[derive(Clone, Copy, Debug, PartialEq)]
124pub enum Course {
125    /// Clockwise around the frame center. The head's angle is measured clockwise on
126    /// screen from the right; loops start with the head at the top.
127    Lap {
128        /// Where the head is on the lap.
129        angle_radians: f64,
130    },
131    /// In place, in the still pose turned by the heading offset and moved by the drift.
132    Hover {
133        /// How far the still heading is turned.
134        heading_offset_radians: f64,
135        /// How far the koi is moved from the hover center, in native pixels.
136        drift_pixels: [f64; 2],
137    },
138}
139
140/// How the koi's body and fins look in one frame. Paired values list the left fin,
141/// then the right, as the koi sees them.
142#[derive(Clone, Copy, Debug, PartialEq)]
143pub struct SwimPose {
144    /// Where the koi is in the frame.
145    pub course: Course,
146    /// How far under the water the koi is, from zero at the surface to one.
147    pub depth: f64,
148    /// Where the body wave is in its cycle.
149    pub wave_phase_radians: f64,
150    /// Multiplies the still pose's wave amplitude.
151    pub wave_strength: f64,
152    /// Bend along each tail lobe; the lobes trail the wave slightly out of step.
153    pub tail_lobe_bends_radians: [f64; 2],
154    /// How far each tail lobe turns outward, opening the fan.
155    pub tail_spread_radians: f64,
156    /// How far each side fin turns outward from its still direction; negative tucks
157    /// it toward the body.
158    pub side_fin_openings_radians: [f64; 2],
159    /// Bend along each side fin, so its tip trails its root.
160    pub side_fin_bends_radians: [f64; 2],
161    /// How far each pelvic fin turns outward.
162    pub pelvic_fin_opening_radians: f64,
163    /// The whiskers swing together, trailing the head.
164    pub whisker_sway_radians: f64,
165    /// Where surface feeding's ripples and pellets are in their cycle, from zero up
166    /// to one; none in every other behavior.
167    pub feeding_phase: Option<f64>,
168    /// How far the mouth is open, from zero closed to one wide open.
169    pub mouth_opening: f64,
170}
171
172/// The still image: hovering at the surface without turning or drifting, with the
173/// gentle wave and resting fins.
174pub(crate) const STILL_POSE: SwimPose = SwimPose {
175    course: Course::Hover {
176        heading_offset_radians: 0.0,
177        drift_pixels: [0.0; 2],
178    },
179    depth: 0.0,
180    wave_phase_radians: 0.8,
181    wave_strength: 1.0,
182    tail_lobe_bends_radians: [0.0; 2],
183    tail_spread_radians: 0.0,
184    side_fin_openings_radians: [0.0; 2],
185    side_fin_bends_radians: [0.0; 2],
186    pelvic_fin_opening_radians: 0.0,
187    whisker_sway_radians: 0.0,
188    feeding_phase: None,
189    mouth_opening: 0.0,
190};
191
192/// Frames in a behavior's loop at FRAMES_PER_SECOND.
193pub fn frame_count(behavior: Behavior) -> u32 {
194    match behavior {
195        Behavior::Cruise => CRUISE_STYLE.loop_frame_count,
196        Behavior::Dart => DART_STYLE.loop_frame_count,
197        Behavior::Rest | Behavior::SurfaceFeeding => REST_FRAME_COUNT,
198        Behavior::DeepRest => DEEP_REST_FRAME_COUNT,
199    }
200}
201
202/// The pose for a frame of a behavior's loop; frame numbers wrap around the loop, so
203/// frame `frame_count(behavior)` is frame zero again.
204///
205/// Cruising and darting swim clockwise laps driven by strokes, each a beat and a
206/// glide. The koi speeds up a little after each stroke and coasts through the glide;
207/// lap speeds are rescaled so the loop covers exactly its laps. Cruising side fins
208/// brake open on the glide; darting side fins tuck through each burst and spread
209/// through the glide. Resting hovers a little deeper with a slow beat and glide and
210/// sculling fins; surface feeding moves the same way at the surface, with ripples
211/// and pellets; deep rest hovers lower still, barely moving, with one fin flick per
212/// loop. In every behavior the tail lobes bend behind the wave, the side fins' tips
213/// trail their roots, the pelvic fins follow at half the opening, and the whiskers
214/// sway. The mouth breathes while the koi laps, gulps while it rests or feeds, and
215/// takes one slow gulp with deep rest's fin flick.
216pub fn calculate_swim_pose(behavior: Behavior, frame_index: u32) -> SwimPose {
217    let loop_frame_count = frame_count(behavior);
218    let loop_frame = frame_index % loop_frame_count;
219    let drive = calculate_drive(behavior, loop_frame);
220    let trailing_drive = calculate_drive(
221        behavior,
222        (loop_frame + loop_frame_count - SIDE_FIN_TRAIL_FRAMES) % loop_frame_count,
223    );
224    let tail_bend = TAIL_BEND_PER_WAVE_STRENGTH
225        * drive.wave_strength
226        * (drive.wave_phase_radians - TAIL_BEND_LAG_RADIANS).sin();
227    let tail_split = TAIL_SPLIT_RADIANS * (drive.wave_phase_radians - TAIL_SPLIT_LAG_RADIANS).sin();
228    let side_fin_openings = drive.calculate_side_fin_openings();
229    let trailing_openings = trailing_drive.calculate_side_fin_openings();
230    SwimPose {
231        course: drive.course,
232        depth: drive.depth,
233        wave_phase_radians: drive.wave_phase_radians,
234        wave_strength: drive.wave_strength,
235        tail_lobe_bends_radians: [tail_bend - tail_split, tail_bend + tail_split],
236        tail_spread_radians: drive.tail_spread_radians,
237        side_fin_openings_radians: side_fin_openings,
238        side_fin_bends_radians: [
239            trailing_openings[0] - side_fin_openings[0],
240            trailing_openings[1] - side_fin_openings[1],
241        ],
242        pelvic_fin_opening_radians: PELVIC_SHARE_OF_SIDE_FIN_OPENING * drive.stroke_opening_radians,
243        whisker_sway_radians: WHISKER_SWAY_RADIANS
244            * (drive.wave_phase_radians - WHISKER_SWAY_LAG_RADIANS).sin(),
245        feeding_phase: drive.feeding_phase,
246        mouth_opening: drive.mouth_opening,
247    }
248}
249
250/// What a behavior decides for one frame; the tail, fin trail, pelvic fins, and
251/// whiskers follow from it the same way for every behavior.
252struct Drive {
253    course: Course,
254    depth: f64,
255    wave_phase_radians: f64,
256    wave_strength: f64,
257    tail_spread_radians: f64,
258    /// The outward turn both side fins share.
259    stroke_opening_radians: f64,
260    /// Opens the right fin and tucks the left by the same amount: a flare on the
261    /// inside of the turn, a scull, or a flick.
262    right_fin_offset_radians: f64,
263    feeding_phase: Option<f64>,
264    mouth_opening: f64,
265}
266
267impl Drive {
268    fn calculate_side_fin_openings(&self) -> [f64; 2] {
269        [
270            self.stroke_opening_radians - self.right_fin_offset_radians,
271            self.stroke_opening_radians + self.right_fin_offset_radians,
272        ]
273    }
274}
275
276fn calculate_drive(behavior: Behavior, loop_frame: u32) -> Drive {
277    match behavior {
278        Behavior::Cruise => CRUISE_STYLE.calculate_drive(loop_frame),
279        Behavior::Dart => DART_STYLE.calculate_drive(loop_frame),
280        Behavior::Rest => calculate_rest_drive(loop_frame, REST_DEPTH),
281        Behavior::SurfaceFeeding => Drive {
282            feeding_phase: Some(
283                (FEEDING_CYCLES_PER_LOOP * f64::from(loop_frame) / f64::from(REST_FRAME_COUNT))
284                    .fract(),
285            ),
286            ..calculate_rest_drive(loop_frame, 0.0)
287        },
288        Behavior::DeepRest => calculate_deep_rest_drive(loop_frame),
289    }
290}
291
292/// Hovers in place at the given depth, swinging and drifting slowly, with two slow
293/// beats and glides and gently sculling side fins.
294fn calculate_rest_drive(loop_frame: u32, depth: f64) -> Drive {
295    const STROKE_FRAME_COUNT: f64 = 45.0;
296    const STROKE_SHARE: f64 = 0.4;
297    const SWING_RADIANS: f64 = 0.1;
298    const DRIFT_PIXELS: [f64; 2] = [3.0, 2.0];
299    const WAVE_FRAME_COUNT: f64 = 15.0;
300    const GLIDE_WAVE_STRENGTH: f64 = 0.15;
301    const STROKE_WAVE_GAIN: f64 = 0.5;
302    const TAIL_SPREAD_RADIANS: f64 = 0.04;
303    const SIDE_FIN_OPENING_RADIANS: f64 = 0.25;
304    const SIDE_FIN_BREATH_RADIANS: f64 = 0.08;
305    const SCULL_SWING_RADIANS: f64 = 0.12;
306    const SCULL_FRAME_COUNT: f64 = 18.0;
307    let frame = f64::from(loop_frame);
308    let loop_turn = TAU * frame / f64::from(REST_FRAME_COUNT);
309    let stroke = calculate_beat(frame, STROKE_FRAME_COUNT, STROKE_SHARE);
310    Drive {
311        course: Course::Hover {
312            heading_offset_radians: SWING_RADIANS * loop_turn.sin(),
313            drift_pixels: [
314                -DRIFT_PIXELS[0] * loop_turn.sin(),
315                -DRIFT_PIXELS[1] * (2.0 * loop_turn + 1.0).sin(),
316            ],
317        },
318        depth,
319        wave_phase_radians: STILL_POSE.wave_phase_radians + frame * TAU / WAVE_FRAME_COUNT,
320        wave_strength: GLIDE_WAVE_STRENGTH + STROKE_WAVE_GAIN * stroke,
321        tail_spread_radians: TAIL_SPREAD_RADIANS,
322        stroke_opening_radians: SIDE_FIN_OPENING_RADIANS
323            + SIDE_FIN_BREATH_RADIANS * (3.0 * loop_turn).sin(),
324        right_fin_offset_radians: SCULL_SWING_RADIANS * (TAU * frame / SCULL_FRAME_COUNT).sin(),
325        feeding_phase: None,
326        mouth_opening: (GULPS_PER_LOOP * loop_turn).sin().max(0.0),
327    }
328}
329
330/// Hovers lower still and barely moves: a slow swing and drift, a breathing wave, and
331/// one flick per loop in which the right fin opens as the left tucks.
332fn calculate_deep_rest_drive(loop_frame: u32) -> Drive {
333    const SWING_RADIANS: f64 = 0.05;
334    const DRIFT_PIXELS: [f64; 2] = [1.5, 1.0];
335    const SINK_PIXELS: f64 = 4.0;
336    const WAVE_FRAME_COUNT: f64 = 30.0;
337    const WAVE_STRENGTH: f64 = 0.12;
338    const WAVE_BREATH: f64 = 0.1;
339    const TAIL_SPREAD_RADIANS: f64 = 0.02;
340    const SIDE_FIN_OPENING_RADIANS: f64 = 0.2;
341    const FLICK_RADIANS: f64 = 0.35;
342    const FLICK_START_FRAME: f64 = 70.0;
343    const FLICK_FRAME_COUNT: f64 = 8.0;
344    // One slow gulp that begins before the flick and ends after it.
345    const GULP_START_FRAME: f64 = 66.0;
346    const GULP_FRAME_COUNT: f64 = 16.0;
347    let frame = f64::from(loop_frame);
348    let loop_turn = TAU * frame / f64::from(DEEP_REST_FRAME_COUNT);
349    let flick_fraction = (frame - FLICK_START_FRAME) / FLICK_FRAME_COUNT;
350    let flick = if (0.0..=1.0).contains(&flick_fraction) {
351        (PI * flick_fraction).sin()
352    } else {
353        0.0
354    };
355    let gulp_fraction = (frame - GULP_START_FRAME) / GULP_FRAME_COUNT;
356    let mouth_opening = if (0.0..=1.0).contains(&gulp_fraction) {
357        (PI * gulp_fraction).sin()
358    } else {
359        0.0
360    };
361    Drive {
362        course: Course::Hover {
363            heading_offset_radians: SWING_RADIANS * loop_turn.sin(),
364            drift_pixels: [
365                -DRIFT_PIXELS[0] * loop_turn.sin(),
366                SINK_PIXELS - DRIFT_PIXELS[1] * (2.0 * loop_turn).sin(),
367            ],
368        },
369        depth: DEEP_REST_DEPTH,
370        wave_phase_radians: STILL_POSE.wave_phase_radians + frame * TAU / WAVE_FRAME_COUNT,
371        wave_strength: WAVE_STRENGTH + WAVE_BREATH * (2.0 * loop_turn).sin(),
372        tail_spread_radians: TAIL_SPREAD_RADIANS,
373        stroke_opening_radians: SIDE_FIN_OPENING_RADIANS,
374        right_fin_offset_radians: FLICK_RADIANS * flick,
375        feeding_phase: None,
376        mouth_opening,
377    }
378}
379
380/// Beat strength at a frame: a smooth beat over the first part of each cycle, then
381/// nothing through the glide.
382fn calculate_beat(frame: f64, cycle_frame_count: f64, beat_share: f64) -> f64 {
383    let cycle_fraction = frame.rem_euclid(cycle_frame_count) / cycle_frame_count;
384    if cycle_fraction < beat_share {
385        (PI * cycle_fraction / beat_share).sin()
386    } else {
387        0.0
388    }
389}
390
391impl LapStyle {
392    /// Where a frame falls in its stroke cycle, from zero up to one.
393    fn calculate_cycle_fraction(&self, frame: f64) -> f64 {
394        frame.rem_euclid(self.stroke_frame_count) / self.stroke_frame_count
395    }
396
397    fn calculate_stroke(&self, frame: f64) -> f64 {
398        calculate_beat(frame, self.stroke_frame_count, self.stroke_share)
399    }
400
401    fn calculate_drive(&self, loop_frame: u32) -> Drive {
402        let lap_speeds: Vec<f64> = (0..self.loop_frame_count)
403            .map(|speed_frame| {
404                self.glide_speed
405                    + self.stroke_speed_gain
406                        * self.calculate_stroke(f64::from(speed_frame) - self.speed_lag_frames)
407            })
408            .collect();
409        let total_speed: f64 = lap_speeds.iter().sum();
410        let covered_speed: f64 = lap_speeds[..loop_frame as usize].iter().sum();
411        let frame = f64::from(loop_frame);
412        let stroke = self.calculate_stroke(frame);
413        let wave_phase_radians =
414            STILL_POSE.wave_phase_radians + frame * TAU / self.wave_frame_count;
415        let scull = self.scull_swing_radians
416            * (TAU * frame / self.scull_frame_count + self.scull_phase_radians).sin();
417        let breath = (TAU * BREATHS_PER_LOOP * frame / f64::from(self.loop_frame_count))
418            .sin()
419            .max(0.0);
420        Drive {
421            course: Course::Lap {
422                angle_radians: -PI / 2.0 + TAU * self.lap_count * covered_speed / total_speed,
423            },
424            depth: 0.0,
425            wave_phase_radians,
426            wave_strength: self.glide_wave_strength + self.stroke_wave_gain * stroke,
427            tail_spread_radians: self.glide_tail_spread_radians
428                + self.stroke_tail_spread_radians * stroke * wave_phase_radians.sin().abs(),
429            stroke_opening_radians: self.calculate_stroke_opening(frame),
430            right_fin_offset_radians: self.inside_flare_radians + scull,
431            feeding_phase: None,
432            mouth_opening: BREATH_OPENING * breath * breath,
433        }
434    }
435
436    /// The outward turn both side fins share with the stroke.
437    fn calculate_stroke_opening(&self, frame: f64) -> f64 {
438        match self.side_fin_strokes {
439            SideFinStrokes::Brake {
440                stroke_opening_radians,
441                brake_opening_radians,
442            } => {
443                stroke_opening_radians
444                    + brake_opening_radians * (1.0 - self.calculate_stroke(frame))
445            }
446            SideFinStrokes::TuckAndSpread {
447                tuck_radians,
448                spread_radians,
449            } => {
450                let cycle_fraction = self.calculate_cycle_fraction(frame);
451                if cycle_fraction < self.stroke_share {
452                    -tuck_radians * self.calculate_stroke(frame)
453                } else {
454                    let glide_fraction =
455                        (cycle_fraction - self.stroke_share) / (1.0 - self.stroke_share);
456                    spread_radians * (PI * glide_fraction).sin()
457                }
458            }
459        }
460    }
461}