mirage-engine 0.1.1

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
use core::time::Duration;

use crate::animation::Progress;
use crate::mesh::{Animation, Clip, Posing, Sampled, Timeline};

/// The pace a motion runs at until one is set.
const PACED: f32 = 1.0;

/// The whole of one cycle.
const WHOLE: f32 = 1.0;

/// What a state plays: one clip, two of them blended, or one a value of the
/// game's own scrubs.
///
/// Every clip runs on its own timeline, so a pace of `1.0` plays it as long
/// as its source states.
#[derive(Clone, Debug, PartialEq)]
pub struct Motion<C: Clip> {
    plays: Plays<C>,
    pace: f32,
}

impl<C: Clip> Motion<C> {
    /// Plays `clip` over and over, wrapping from its last key to its first.
    pub fn looping(clip: C) -> Self {
        Self::of(Plays::Looping(clip))
    }

    /// Plays `clip` once and holds its last key.
    pub fn once(clip: C) -> Self {
        Self::of(Plays::Once(clip))
    }

    /// Plays `from` and `to` together, `weight` of the way to `to`, as a
    /// fraction held inside `0.0..=1.0` and read as `0.0` where it is not a
    /// number.
    ///
    /// Each is read at the same fraction of its own timeline and both count
    /// cycles together, so a walk and a run of different lengths keep their
    /// steps together.
    pub fn blend(from: C, to: C, weight: f32) -> Self {
        Self::of(Plays::Blended {
            from,
            to,
            weight: within(weight),
        })
    }

    /// Holds `clip` at `fraction` of the way along it, a fraction held
    /// inside `0.0..=1.0` and read as `0.0` where it is not a number.
    ///
    /// Required if you want a value of the game's own to pose a mesh: no
    /// clock moves it, so a door opens as far as the game states and no
    /// further.
    pub fn scrubbed(clip: C, fraction: f32) -> Self {
        Self::of(Plays::Scrubbed {
            clip,
            fraction: within(fraction),
        })
    }

    /// Runs at `rate` of the pace its source states, a fraction that is `1.0`
    /// until set.
    ///
    /// A rate of nothing or less holds the motion where it is, and one
    /// that is not a number reads as `1.0`. The last call is the one used.
    pub fn paced(mut self, rate: f32) -> Self {
        self.pace = paced(rate);
        self
    }

    /// The motion that reads `plays` at the pace its source states.
    fn of(plays: Plays<C>) -> Self {
        Self { plays, pace: PACED }
    }
}

/// What a motion reads, by whatever names its clips: a game's own
/// vocabulary where a state states it, their places in that vocabulary
/// where a machine runs it.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Plays<C> {
    Looping(C),
    Once(C),
    Blended { from: C, to: C, weight: f32 },
    Scrubbed { clip: C, fraction: f32 },
}

impl<C: Clip> Plays<C> {
    /// The same, with every clip by its place in its vocabulary.
    fn indexed(&self) -> Plays<u32> {
        match self {
            Self::Looping(clip) => Plays::Looping(clip.index()),
            Self::Once(clip) => Plays::Once(clip.index()),
            Self::Blended { from, to, weight } => Plays::Blended {
                from: from.index(),
                to: to.index(),
                weight: *weight,
            },
            Self::Scrubbed { clip, fraction } => Plays::Scrubbed {
                clip: clip.index(),
                fraction: *fraction,
            },
        }
    }
}

impl<C: PartialEq> Plays<C> {
    /// Whether both read the same clips the same way, whatever weight or
    /// fraction each of them holds.
    fn same_clips(&self, other: &Self) -> bool {
        match (self, other) {
            (Self::Looping(one), Self::Looping(two)) | (Self::Once(one), Self::Once(two)) => {
                one == two
            }
            (
                Self::Blended { from, to, .. },
                Self::Blended {
                    from: other_from,
                    to: other_to,
                    ..
                },
            ) => from == other_from && to == other_to,
            (Self::Scrubbed { clip, .. }, Self::Scrubbed { clip: other, .. }) => clip == other,
            _ => false,
        }
    }
}

/// A motion and where a machine is in it: the clips by their places, the
/// pace it runs at, the instant it started and the cycles it started at.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Timed {
    plays: Plays<u32>,
    pace: f32,
    /// The instant it started, absent until `animate` first runs the
    /// machine, which leaves it where it starts.
    started: Option<Duration>,
    /// The cycles it had run when it started, which entering part way in
    /// sets.
    from: f32,
}

impl Timed {
    /// The motion `motion` states, started at `started` and `from` cycles
    /// into it.
    pub(crate) fn new<C: Clip>(motion: &Motion<C>, started: Option<Duration>, from: f32) -> Self {
        Self {
            plays: motion.plays.indexed(),
            pace: motion.pace,
            started,
            from,
        }
    }

    /// How far along the motion the machine is at `now`.
    pub(crate) fn progress(&self, now: Duration, clips: &[Animation]) -> Progress {
        Progress::new(self.run(now, clips), self.ends())
    }

    /// The pose it holds at `now`.
    pub(crate) fn posing(&self, now: Duration, clips: &[Animation]) -> Posing {
        self.reading(now, clips).posing()
    }

    /// `base` blended `weight` of the way to the pose it holds at `now`.
    pub(crate) fn blended_over(
        &self,
        base: Posing,
        weight: f32,
        now: Duration,
        clips: &[Animation],
    ) -> Posing {
        self.reading(now, clips)
            .blended_over(base, weight.clamp(0.0, WHOLE))
    }

    /// The motion `motion` states, kept where the machine already is in
    /// it where it reads the same clips and started afresh where it does
    /// not.
    ///
    /// A change of pace or of weight alone moves the start instant, so a
    /// loop keeps the position it is at and a blend the cycle it is in.
    pub(crate) fn kept<C: Clip>(
        self,
        motion: &Motion<C>,
        now: Duration,
        clips: &[Animation],
    ) -> Self {
        let plays = motion.plays.indexed();
        if plays == self.plays && motion.pace == self.pace {
            return self;
        }
        if !plays.same_clips(&self.plays) {
            return Self::new(motion, Some(now), 0.0);
        }

        Self {
            plays,
            pace: motion.pace,
            started: Some(now),
            from: self.run(now, clips),
        }
    }

    /// The same motion, started at `now` where no clock has run it yet.
    pub(crate) fn started_at(mut self, now: Duration) -> Self {
        self.started = self.started.or(Some(now));
        self
    }

    /// The same motion started `span` later, which leaves it where it is
    /// while the machine is held.
    pub(crate) fn shifted(mut self, span: Duration) -> Self {
        self.started = self
            .started
            .map(|started| started.checked_add(span).unwrap_or(started));
        self
    }

    /// The cycles of it that have run by `now`.
    fn run(&self, now: Duration, clips: &[Animation]) -> f32 {
        if let Plays::Scrubbed { fraction, .. } = self.plays {
            return fraction;
        }
        let cycle = self.cycle(clips);
        if cycle <= 0.0 {
            // A clip with no keys holds every joint at rest, so a motion
            // that ends has ended the instant it starts.
            return match self.ends() {
                true => WHOLE,
                false => self.from,
            };
        }
        let Some(started) = self.started else {
            return self.from;
        };

        self.from + now.saturating_sub(started).as_secs_f32() * self.pace / cycle
    }

    /// How long one cycle of it runs for, in seconds; nothing at all where
    /// its clips hold no keys.
    fn cycle(&self, clips: &[Animation]) -> f32 {
        match self.plays {
            Plays::Looping(clip) | Plays::Once(clip) => timeline(clip, clips).span(),
            Plays::Blended { from, to, weight } => {
                let together = rate(timeline(from, clips).span(), WHOLE - weight)
                    + rate(timeline(to, clips).span(), weight);
                match together.is_finite() && together > 0.0 {
                    true => WHOLE / together,
                    false => 0.0,
                }
            }
            Plays::Scrubbed { .. } => 0.0,
        }
    }

    /// Whether it holds at the end of one cycle rather than wrapping.
    fn ends(&self) -> bool {
        matches!(self.plays, Plays::Once(_) | Plays::Scrubbed { .. })
    }

    /// What it reads at `now`.
    fn reading(&self, now: Duration, clips: &[Animation]) -> Reading {
        let run = self.run(now, clips);
        match self.plays {
            Plays::Looping(clip) => Reading::One(sampled(clip, run.rem_euclid(WHOLE), clips)),
            Plays::Once(clip) => Reading::One(sampled(clip, run.clamp(0.0, WHOLE), clips)),
            Plays::Scrubbed { clip, fraction } => Reading::One(sampled(clip, fraction, clips)),
            Plays::Blended { from, to, weight } => {
                let phase = run.rem_euclid(WHOLE);
                Reading::Pair {
                    from: sampled(from, phase, clips),
                    to: sampled(to, phase, clips),
                    weight,
                }
            }
        }
    }
}

/// What a motion reads at one instant: one clip, or the pair a blend
/// reads, each at the same fraction of its own timeline.
enum Reading {
    One(Sampled),
    Pair {
        from: Sampled,
        to: Sampled,
        weight: f32,
    },
}

impl Reading {
    /// The pose it holds on its own.
    fn posing(self) -> Posing {
        match self {
            Self::One(sampled) => Posing::clip(sampled.clip, sampled.at),
            Self::Pair { from, to, weight } => Posing::clip(from.clip, from.at).blended(to, weight),
        }
    }

    /// `base` blended `weight` of the way to it.
    ///
    /// A pair takes two places of the pose; the first is scaled so that the
    /// two of them together read as one fade from what lies under them to
    /// the pair, which two fades blended one over the other are not on
    /// their own.
    fn blended_over(self, base: Posing, weight: f32) -> Posing {
        match self {
            Self::One(sampled) => base.blended(sampled, weight),
            Self::Pair {
                from,
                to,
                weight: between,
            } => {
                let taken = weight * between;
                let first = match taken < WHOLE {
                    true => weight * (WHOLE - between) / (WHOLE - taken),
                    false => 0.0,
                };
                base.blended(from, first).blended(to, taken)
            }
        }
    }
}

/// The timeline of the clip at `clip`, which is empty where the mesh drawn
/// holds no such clip.
fn timeline(clip: u32, clips: &[Animation]) -> Timeline {
    clips
        .get(clip as usize)
        .map(Animation::timeline)
        .unwrap_or_default()
}

/// The clip at `clip` read `fraction` of the way along its own timeline.
fn sampled(clip: u32, fraction: f32, clips: &[Animation]) -> Sampled {
    Sampled {
        clip,
        at: timeline(clip, clips).at(fraction),
    }
}

/// The cycles a second a clip `span` seconds long runs at for the `share`
/// of a blend it takes: nothing where it takes none of the blend, and
/// without end where the clip holds no keys at all.
fn rate(span: f32, share: f32) -> f32 {
    if share <= 0.0 {
        return 0.0;
    }
    match span > 0.0 {
        true => share / span,
        false => f32::INFINITY,
    }
}

/// `value` inside `0.0..=1.0`, and nothing at all where it is not a number.
fn within(value: f32) -> f32 {
    match value.is_nan() {
        true => 0.0,
        false => value.clamp(0.0, WHOLE),
    }
}

/// `rate` from nothing up, and the pace its source states where it is not a
/// number.
fn paced(rate: f32) -> f32 {
    match rate.is_nan() {
        true => PACED,
        false => rate.max(0.0),
    }
}