use bevy::{
asset::{AssetLoader, LoadContext, io::Reader},
reflect::TypePath,
};
use thiserror::Error;
use crate::sequence::SequenceAsset;
#[derive(Error, Debug)]
pub enum SequenceError {
#[error("could not read sequence: {0}")]
Io(#[from] std::io::Error),
#[error("could not parse sequence RON: {0}")]
Parse(#[from] ron::error::SpannedError),
#[error("could not serialize sequence: {0}")]
Serialize(#[from] ron::Error),
}
pub fn parse_sequence(bytes: &[u8]) -> Result<SequenceAsset, SequenceError> {
Ok(ron::de::from_bytes(bytes)?)
}
#[derive(Default, TypePath)]
pub struct SequenceLoader;
impl AssetLoader for SequenceLoader {
type Asset = SequenceAsset;
type Settings = ();
type Error = SequenceError;
async fn load(
&self,
reader: &mut dyn Reader,
_settings: &(),
_load_context: &mut LoadContext<'_>,
) -> Result<Self::Asset, Self::Error> {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await?;
parse_sequence(&bytes)
}
fn extensions(&self) -> &[&str] {
&["dir.ron"]
}
}
pub fn to_ron_string(sequence: &SequenceAsset) -> Result<String, SequenceError> {
let pretty = ron::ser::PrettyConfig::new()
.depth_limit(6)
.indentor(" ");
Ok(ron::ser::to_string_pretty(sequence, pretty)?)
}
#[cfg(all(feature = "viewfinder", not(target_arch = "wasm32")))]
pub fn save_sequence(
sequence: &SequenceAsset,
path: &std::path::Path,
) -> Result<(), SequenceError> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, to_ron_string(sequence)?)?;
Ok(())
}
#[cfg(all(feature = "viewfinder", not(target_arch = "wasm32")))]
pub fn load_sequence(path: &std::path::Path) -> Result<SequenceAsset, SequenceError> {
parse_sequence(&std::fs::read(path)?)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sequence::*;
use bevy::prelude::*;
fn sample() -> SequenceAsset {
SequenceAsset {
name: "roundtrip".into(),
shots: vec![Shot {
start: 0.0,
duration: 3.0,
blend_in: Some(Blend {
secs: 0.5,
ease: EaseFunction::SmoothStep,
}),
rig: Rig::Keys {
keys: vec![Key {
time: 0.0,
pos: Vec3::new(1.0, 2.0, 3.0),
rot: Some(Quat::IDENTITY),
ease: EaseFunction::Linear,
}],
interp: KeyInterp::Eased,
},
look: Look::Free,
lens: Lens::default(),
shake: None,
}],
markers: vec![Marker {
time: 1.0,
name: "beat".into(),
}],
texts: vec![
TextBlock::at(0.5, 2.0, "EXT. RIDGE - DAWN")
.anchor(TextAnchor::Center)
.fades(0.5, 0.5)
.style(TextBlockStyle {
font_size: 36.0,
background: Some(Color::srgba(0.0, 0.0, 0.0, 0.6)),
..Default::default()
}),
],
actors: vec![
ActorTrack::entity("hero")
.cue(ActorCue::at(0.0, 1.5, "cutscene.sleeping").fades(0.0, 0.4))
.cue(ActorCue::at(1.5, 1.5, "cutscene.wake_up").fades(0.4, 0.0).speed(0.9)),
],
blend_out: Some(Blend {
secs: 1.5,
ease: EaseFunction::SmoothStep,
}),
}
}
#[test]
fn ron_round_trip_is_stable() {
let first = to_ron_string(&sample()).unwrap();
let back = parse_sequence(first.as_bytes()).unwrap();
let second = to_ron_string(&back).unwrap();
assert_eq!(first, second);
assert_eq!(back.name, "roundtrip");
assert_eq!(back.shots.len(), 1);
assert_eq!(back.markers[0].name, "beat");
assert_eq!(back.texts.len(), 1);
assert_eq!(back.texts[0].text, "EXT. RIDGE - DAWN");
assert_eq!(back.texts[0].anchor, TextAnchor::Center);
assert_eq!(back.actors.len(), 1);
assert_eq!(back.actors[0].cues.len(), 2);
assert_eq!(back.actors[0].cues[1].anim, "cutscene.wake_up");
assert!((back.actors[0].cues[1].speed - 0.9).abs() < 1e-6);
}
#[test]
fn parse_accepts_minimal_and_defaults_the_rest() {
let text = r#"SequenceAsset(
name: "minimal",
shots: [Shot(
start: 0.0, duration: 2.0,
rig: Keys(keys: [Key(time: 0.0, pos: (0.0, 1.0, 2.0), rot: Some((0.0, 0.0, 0.0, 1.0)))]),
)],
)"#;
let seq = parse_sequence(text.as_bytes()).unwrap();
assert!(seq.markers.is_empty());
assert!(seq.texts.is_empty());
assert!(seq.blend_out.is_none());
assert!(seq.shots[0].blend_in.is_none());
assert!(matches!(seq.shots[0].look, Look::Free));
}
#[test]
fn texts_absent_from_ron_when_empty() {
let mut sequence = sample();
sequence.texts.clear();
let ron = to_ron_string(&sequence).unwrap();
assert!(!ron.contains("texts"), "unexpected texts field:\n{ron}");
}
#[test]
fn actors_absent_from_ron_when_empty() {
let mut sequence = sample();
sequence.actors.clear();
let ron = to_ron_string(&sequence).unwrap();
assert!(!ron.contains("actors"), "unexpected actors field:\n{ron}");
}
#[test]
fn parse_ignores_unknown_fields() {
let text = r#"SequenceAsset(
name: "forward",
shots: [Shot(
start: 0.0, duration: 2.0,
rig: Keys(keys: [Key(time: 0.0, pos: (0.0, 1.0, 2.0), rot: Some((0.0, 0.0, 0.0, 1.0)))]),
)],
future_field: 1,
)"#;
let seq = parse_sequence(text.as_bytes()).unwrap();
assert_eq!(seq.name, "forward");
}
#[test]
fn parse_rejects_garbage() {
assert!(parse_sequence(b"not a sequence at all").is_err());
assert!(parse_sequence(b"SequenceAsset(name: 3)").is_err());
}
}