bevy_where_was_i/
lib.rs

1#![warn(missing_docs)]
2#![doc = include_str!("../README.md")]
3
4use std::fs;
5use std::io::{self, BufRead};
6use std::path::Path;
7use std::{fs::File, io::BufWriter};
8
9use bevy::prelude::*;
10use bevy::window::WindowClosing;
11use serialization::{deserialize_transform, serialize_transform};
12
13mod serialization;
14
15/// A component that saves a [`Transform`] to disk and restores it when you reopn the application.
16///
17/// It requires a [`Transform`]. You can omit it and Bevy will create a [`Transform::IDENTITY`] for
18/// you.
19///
20/// ```rust
21/// use bevy_where_was_i::WhereWasI;
22///
23/// WhereWasI::from_name("my_entity");
24/// ```
25#[derive(Component)]
26#[require(Transform)]
27pub struct WhereWasI {
28    name: String,
29}
30
31impl WhereWasI {
32    /// Construct a [`WhereWasI`] plugin with a name
33    pub fn from_name(name: &str) -> Self {
34        Self { name: name.into() }
35    }
36
37    /// A shorthand used for cameras
38    ///
39    /// Equivalent to:
40    /// ```rust
41    /// use bevy_where_was_i::WhereWasI;
42    ///
43    /// WhereWasI::from_name("camera");
44    /// ```
45    pub fn camera() -> Self {
46        WhereWasI::from_name("camera")
47    }
48}
49
50/// A [`Resource`] to store the `directory` in so we can access in the systems of this plugin.
51#[derive(Resource)]
52struct WhereWasIConfig {
53    directory: String,
54}
55
56/// Plugin that saves the [`Transform`] state after closing a Bevy application, and restores it
57/// when launching the application again.
58pub struct WhereWasIPlugin {
59    /// The directory where savefiles will be stored and loaded from
60    pub directory: String,
61}
62
63impl Default for WhereWasIPlugin {
64    fn default() -> Self {
65        Self {
66            directory: "./assets/saves".into(),
67        }
68    }
69}
70
71impl Plugin for WhereWasIPlugin {
72    fn build(&self, app: &mut App) {
73        app.insert_resource(WhereWasIConfig {
74            directory: self.directory.clone(),
75        })
76        .add_systems(Update, save_state)
77        .add_systems(PostStartup, load_state);
78    }
79}
80
81/// Read file `filename` line-by-line
82fn read_lines<P>(filename: P) -> io::Result<io::Lines<io::BufReader<File>>>
83where
84    P: AsRef<Path>,
85{
86    let file = File::open(filename)?;
87    Ok(io::BufReader::new(file).lines())
88}
89
90/// Load the state of all [`Transform`]s belonging to [`WhereWasI`] components
91fn load_state(mut to_save: Query<(&WhereWasI, &mut Transform)>, config: Res<WhereWasIConfig>) {
92    let mut initialized = 0;
93
94    for (where_was_i, mut transform) in to_save.iter_mut() {
95        let (directory, filename) = (&config.directory, &where_was_i.name);
96        let filepath = format!("{directory}/{filename}.state");
97
98        if let Ok(contents) = read_lines(filepath) {
99            match deserialize_transform(contents) {
100                Ok(new) => {
101                    *transform = new;
102                    initialized += 1;
103                }
104                Err(err) => {
105                    error!("Could not deserialize transform: {}", err.message);
106                }
107            }
108        }
109    }
110
111    info!("Initialized {} transform(s)", initialized);
112}
113
114/// Saves the state of all [`Transform`]s belonging to [`WhereWasI`] components when closing a
115/// window
116///
117/// Note: this doesn't work for WASM.
118fn save_state(
119    mut events: MessageReader<WindowClosing>,
120    to_save: Query<(&WhereWasI, &Transform)>,
121    config: Res<WhereWasIConfig>,
122) {
123    let directory = &config.directory;
124    let mut saved_files = 0;
125
126    if events.read().next().is_some() {
127        for (where_was_i, transform) in to_save.iter() {
128            let filename = where_was_i.name.clone();
129
130            if let Ok(false) = fs::exists(directory) {
131                fs::create_dir_all(directory).expect("Could not create directory");
132            }
133
134            let mut writer = BufWriter::new(
135                File::create(format!("{directory}/{filename}.state"))
136                    .expect("Error occurred while opening file"),
137            );
138
139            #[cfg(not(target_arch = "wasm32"))]
140            serialize_transform(&mut writer, transform)
141                .expect("Error occurred while writing to disk");
142
143            saved_files += 1;
144        }
145        info!("Saved {} transforms to: {}", saved_files, directory);
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    const TRANSFORM: Transform = Transform {
154        translation: Vec3::new(4.0, 3.5, -2.0),
155        rotation: Quat::from_xyzw(-0.1, 0.7, 0.4, 0.6),
156        scale: Vec3::new(12.6, -1.0, 2.4),
157    };
158    const SAVE_STATE_FILE: &str = "assets/tests/system_save_test.state";
159
160    fn setup_camera_with_transform(mut commands: Commands<'_, '_>) {
161        commands.spawn((WhereWasI::from_name("system_save_test"), TRANSFORM));
162    }
163
164    fn setup_camera_without_transform(mut commands: Commands<'_, '_>) {
165        commands.spawn((Camera::default(), WhereWasI::camera()));
166    }
167
168    #[test]
169    fn test_save() {
170        let mut app = App::new();
171
172        if let Ok(true) = fs::exists(SAVE_STATE_FILE) {
173            fs::remove_file("assets/tests/system_save_test.state").unwrap();
174        }
175        assert!(!fs::exists(SAVE_STATE_FILE).unwrap());
176
177        app.insert_resource(WhereWasIConfig {
178            directory: "assets/tests".into(),
179        });
180        app.add_message::<WindowClosing>();
181        app.add_systems(Startup, setup_camera_with_transform);
182        app.add_systems(Update, save_state);
183
184        // Send an `WindowClosing` event
185        app.world_mut()
186            .resource_mut::<Messages<WindowClosing>>()
187            .write(WindowClosing {
188                window: Entity::from_raw_u32(322).unwrap(),
189            });
190
191        app.update();
192
193        let lines = read_lines("assets/tests/system_save_test.state").unwrap();
194        assert_eq!(deserialize_transform(lines).unwrap(), TRANSFORM);
195
196        fs::remove_file("assets/tests/system_save_test.state").unwrap();
197    }
198
199    #[test]
200    fn test_load() {
201        let mut app = App::new();
202
203        app.insert_resource(WhereWasIConfig {
204            directory: "assets/tests".into(),
205        });
206        app.add_systems(Startup, setup_camera_without_transform);
207        app.add_systems(Update, load_state);
208
209        app.update();
210
211        let result = app
212            .world_mut()
213            .query::<&Transform>()
214            .single(app.world())
215            .expect("`load_state` should have added a Transform to the world");
216
217        const TRANSFORM: Transform = Transform {
218            translation: Vec3::new(10.000002, 10.0, 10.0),
219            rotation: Quat::from_xyzw(-0.27984813, 0.36470526, 0.11591691, 0.88047624),
220            scale: Vec3::new(1.0, 1.0, 1.0),
221        };
222        assert_eq!(*result, TRANSFORM);
223    }
224}