bevy_mod_yarn/
assets.rs

1//! Custom asset loaders for compiled Yarn files (yarnc) and
2//! the associated string and metadata files as defined in [crate::data].
3
4use std::{collections::HashMap, path::PathBuf};
5
6use bevy::{
7    asset::{AssetLoader, AssetPath, LoadedAsset},
8    prelude::{warn, Handle},
9    reflect::{TypePath, TypeUuid},
10};
11use chapter::{expand_format_functions, Line, LineInfo, MetadataInfo, Program};
12use csv::{Reader, ReaderBuilder};
13use prost::Message;
14use regex::Regex;
15
16/// A newtype wrapping a yarn spinner program that can be loaded
17/// into the bevy engine.
18#[derive(Debug, TypeUuid, TypePath)]
19#[uuid = "e0021061-5ff9-4134-992e-a9352d8854cd"]
20pub struct BevyYarnProgram {
21    /// The program loaded from the yarnc file
22    pub program: Program,
23
24    /// A handle for the string table for this yarnc file
25    pub string_table: Handle<BevyYarnStringTable>,
26
27    /// A handle for the metadata table for this yarnc file
28    pub metadata_table: Handle<BevyYarnMetadataTable>,
29}
30
31pub(crate) fn get_table_pathbuf_from_yarnc_path<P>(yarnc_path: P, prefix: &str) -> PathBuf
32where
33    P: Into<PathBuf>,
34{
35    let mut pb: PathBuf = yarnc_path.into();
36    pb.set_file_name(format!(
37        "{}.{prefix}.csv",
38        pb.file_stem().unwrap().to_str().unwrap()
39    ));
40    pb
41}
42
43/// A custom loader for BevyYarnProgram assets.
44#[derive(Default)]
45pub struct BevyYarnProjectAssetLoader;
46
47impl AssetLoader for BevyYarnProjectAssetLoader {
48    fn load<'a>(
49        &'a self,
50        bytes: &'a [u8],
51        load_context: &'a mut bevy::asset::LoadContext,
52    ) -> bevy::utils::BoxedFuture<'a, Result<(), bevy::asset::Error>> {
53        Box::pin(async move {
54            // First load in the program from the yarnc file
55            let program = Program::decode(bytes)?;
56
57            // Next load the string table, it should have the name `<yarnc-file-name>-Lines.csv`
58            let path = get_table_pathbuf_from_yarnc_path(load_context.path(), "lines");
59            let string_asset_path = AssetPath::new(path, None);
60            let string_table: Handle<BevyYarnStringTable> =
61                load_context.get_handle(string_asset_path.clone());
62
63            // Next load the metadata table, it should have the name `<yarnc-file-name>-Metadata.csv`
64            let path = get_table_pathbuf_from_yarnc_path(load_context.path(), "metadata");
65            let metadata_asset_path = AssetPath::new(path, None);
66            let metadata_table: Handle<BevyYarnMetadataTable> =
67                load_context.get_handle(metadata_asset_path.clone());
68
69            // Finally set all the loaded assets and mark the tables as dependencies
70            load_context.set_default_asset(
71                LoadedAsset::new(BevyYarnProgram {
72                    program,
73                    string_table,
74                    metadata_table,
75                })
76                .with_dependencies(vec![string_asset_path, metadata_asset_path]),
77            );
78
79            Ok(())
80        })
81    }
82
83    fn extensions(&self) -> &[&str] {
84        &["yarnc"]
85    }
86}
87
88/// A resource to contain the string table
89#[derive(Default, Debug, TypeUuid, TypePath)]
90#[uuid = "d11069b5-98c8-4db0-8616-58d86ee1deb3"]
91pub struct BevyYarnStringTable(pub HashMap<String, LineInfo>);
92
93impl BevyYarnStringTable {
94    /// Finds the string for a line from the given string table
95    fn find_string_in_table(&self, id: &String) -> String {
96        if let Some(text) = self.0.get(id).map(|line_info| line_info.text.clone()) {
97            text
98        } else {
99            warn!("Line id {id} missing from string table. Skipping");
100            format!("<missing_string: {id}>")
101        }
102    }
103
104    /// Completes variable substitutions in the given string
105    fn perform_variable_substitutions(initial: String, substitutions: &[String]) -> String {
106        substitutions
107            .iter()
108            .enumerate()
109            .fold(initial, |current, (idx, next_sub)| {
110                current.replace(&format!("{{{idx}}}"), next_sub)
111            })
112    }
113
114    /// Pulls out the character (if any) from the given formatted string.
115    /// Characters are represented by e.g. "character 1 name: line" in the yarn file
116    fn extract_character(formatted_text: String) -> (Option<String>, String) {
117        let character_regex: Regex = Regex::new(r"([a-zA-Z0-9]+:)?\s*(.*)").unwrap();
118
119        match character_regex.captures(&formatted_text) {
120            Some(captures) => {
121                if captures.len() == 3 {
122                    (
123                        captures
124                            .get(1)
125                            .map(|val| val.as_str().to_owned().replace(':', "")),
126                        captures.get(2).unwrap().as_str().to_owned(),
127                    )
128                } else {
129                    (None, formatted_text)
130                }
131            }
132            None => (None, formatted_text),
133        }
134    }
135
136    /// Gets the final substituted and formatted text
137    pub fn get_final_text(&self, line: &Line, local_code: &str) -> (Option<String>, String) {
138        let initial = self.find_string_in_table(&line.id);
139        let (character, initial) = Self::extract_character(initial);
140        let subbed_text = Self::perform_variable_substitutions(initial, &line.substitutions);
141        (character, expand_format_functions(&subbed_text, local_code))
142    }
143}
144
145/// A custom loader for BevyYarnProgram assets.
146#[derive(Default)]
147pub struct BevyYarnStringTableAssetLoader;
148
149impl AssetLoader for BevyYarnStringTableAssetLoader {
150    fn load<'a>(
151        &'a self,
152        bytes: &'a [u8],
153        load_context: &'a mut bevy::asset::LoadContext,
154    ) -> bevy::utils::BoxedFuture<'a, Result<(), bevy::asset::Error>> {
155        Box::pin(async move {
156            let string_table =
157                HashMap::from_iter(Reader::from_reader(bytes).deserialize().map(|result| {
158                    let res: LineInfo = result.unwrap();
159                    (res.id.clone(), res)
160                }));
161
162            load_context.set_default_asset(LoadedAsset::new(BevyYarnStringTable(string_table)));
163
164            Ok(())
165        })
166    }
167
168    fn extensions(&self) -> &[&str] {
169        &["lines.csv"]
170    }
171}
172
173/// A resource to contain the metadata table
174#[derive(Default, Debug, TypeUuid, TypePath)]
175#[uuid = "42073437-7c2b-4526-859c-f1b059881c67"]
176pub struct BevyYarnMetadataTable(pub HashMap<String, MetadataInfo>);
177
178impl BevyYarnMetadataTable {
179    /// Gets the tags associated with a given line, if any
180    pub fn get_tags_for_line(&self, line: &Line) -> Vec<String> {
181        self.0
182            .get(&line.id)
183            .map(|metadata_info| &metadata_info.tags)
184            .cloned()
185            .unwrap_or_default()
186    }
187}
188
189/// A custom loader for BevyYarnProgram assets.
190#[derive(Default)]
191pub struct BevyYarnMetadataTableAssetLoader;
192
193impl AssetLoader for BevyYarnMetadataTableAssetLoader {
194    fn load<'a>(
195        &'a self,
196        bytes: &'a [u8],
197        load_context: &'a mut bevy::asset::LoadContext,
198    ) -> bevy::utils::BoxedFuture<'a, Result<(), bevy::asset::Error>> {
199        Box::pin(async move {
200            let metadata_table = HashMap::from_iter(
201                ReaderBuilder::new()
202                    .flexible(true)
203                    .from_reader(bytes)
204                    .deserialize()
205                    .map(|result| {
206                        if result.is_err() {
207                            warn!("[{:?}] {result:?}\n", load_context.path());
208                        }
209
210                        let res: MetadataInfo = result.unwrap();
211                        (res.id.clone(), res)
212                    }),
213            );
214
215            load_context.set_default_asset(LoadedAsset::new(BevyYarnMetadataTable(metadata_table)));
216
217            Ok(())
218        })
219    }
220
221    fn extensions(&self) -> &[&str] {
222        &["metadata.csv"]
223    }
224}