bevy_yoleck/
level_files_manager.rs

1use std::path::PathBuf;
2use std::{fs, io};
3
4use bevy::platform::collections::HashSet;
5use bevy::prelude::*;
6use bevy_egui::egui;
7
8use crate::editor_panels::YoleckPanelUi;
9use crate::entity_management::{
10    YoleckEntryHeader, YoleckKeepLevel, YoleckLoadLevel, YoleckRawEntry,
11};
12use crate::entity_upgrading::YoleckEntityUpgrading;
13use crate::exclusive_systems::YoleckActiveExclusiveSystem;
14use crate::knobs::YoleckKnobsCache;
15use crate::level_files_upgrading::upgrade_level_file;
16use crate::level_index::YoleckLevelIndexEntry;
17use crate::prelude::{YoleckEditorState, YoleckEntityUuid};
18use crate::{
19    YoleckEditableLevels, YoleckEntityConstructionSpecs, YoleckLevelInEditor,
20    YoleckLevelInPlaytest, YoleckLevelIndex, YoleckManaged, YoleckPlaytestLevel, YoleckRawLevel,
21    YoleckState,
22};
23
24const EXTENSION: &str = ".yol";
25const EXTENSION_WITHOUT_DOT: &str = "yol";
26
27/// The path for the levels directory.
28///
29/// [The plugin](crate::YoleckPluginForEditor) sets it to `./assets/levels/`, but it can be set to
30/// other values:
31/// ```no_run
32/// # use std::path::Path;
33/// # use bevy::prelude::*;
34/// # use bevy_yoleck::YoleckEditorLevelsDirectoryPath;
35/// # let mut app = App::new();
36/// app.insert_resource(YoleckEditorLevelsDirectoryPath(
37///     Path::new(".").join("some").join("other").join("path"),
38/// ));
39/// ```
40#[derive(Resource)]
41pub struct YoleckEditorLevelsDirectoryPath(pub PathBuf);
42
43#[derive(Debug)]
44enum SelectedLevelFile {
45    Unsaved(String),
46    Existing(String),
47}
48
49#[doc(hidden)]
50pub struct LevelFilesManagerTopSectionLocals {
51    should_list_files: bool,
52    loaded_files_index: io::Result<Vec<YoleckLevelIndexEntry>>,
53    file_popup_open: bool,
54    selected_level_file: SelectedLevelFile,
55}
56
57impl Default for LevelFilesManagerTopSectionLocals {
58    fn default() -> Self {
59        Self {
60            should_list_files: true,
61            loaded_files_index: Ok(vec![]),
62            file_popup_open: false,
63            selected_level_file: SelectedLevelFile::Unsaved(String::new()),
64        }
65    }
66}
67
68#[allow(clippy::too_many_arguments)]
69pub fn level_files_manager_top_section(
70    mut ui: ResMut<YoleckPanelUi>,
71    mut locals: Local<LevelFilesManagerTopSectionLocals>,
72    mut commands: Commands,
73    mut yoleck: ResMut<YoleckState>,
74    mut levels_directory: ResMut<YoleckEditorLevelsDirectoryPath>,
75    mut editable_levels: ResMut<YoleckEditableLevels>,
76    construction_specs: Res<YoleckEntityConstructionSpecs>,
77    yoleck_managed_query: Query<(&YoleckManaged, Option<&YoleckEntityUuid>)>,
78    keep_levels_query: Query<Entity, With<YoleckKeepLevel>>,
79    editor_state: Res<State<YoleckEditorState>>,
80    mut knobs_cache: ResMut<YoleckKnobsCache>,
81    mut level_assets: ResMut<Assets<YoleckRawLevel>>,
82    entity_upgrading: Option<Res<YoleckEntityUpgrading>>,
83    active_exclusive_system: Option<Res<YoleckActiveExclusiveSystem>>,
84) -> Result {
85    if active_exclusive_system.is_some() {
86        return Ok(());
87    }
88
89    let LevelFilesManagerTopSectionLocals {
90        should_list_files,
91        loaded_files_index,
92        file_popup_open,
93        selected_level_file,
94    } = &mut *locals;
95
96    let gen_raw_level_file = || {
97        let app_format_version = if let Some(entity_upgrading) = &entity_upgrading {
98            entity_upgrading.app_format_version
99        } else {
100            0
101        };
102        YoleckRawLevel::new(app_format_version, {
103            yoleck_managed_query
104                .iter()
105                .map(|(yoleck_managed, entity_uuid)| YoleckRawEntry {
106                    header: YoleckEntryHeader {
107                        type_name: yoleck_managed.type_name.clone(),
108                        name: yoleck_managed.name.clone(),
109                        uuid: entity_uuid.map(|entity_uuid| entity_uuid.get()),
110                    },
111                    data: {
112                        if let Some(entity_type_info) =
113                            construction_specs.get_entity_type_info(&yoleck_managed.type_name)
114                        {
115                            entity_type_info
116                                .components
117                                .iter()
118                                .filter_map(|component| {
119                                    let component_data =
120                                        yoleck_managed.components_data.get(component)?;
121                                    let handler = &construction_specs.component_handlers[component];
122                                    Some((
123                                        handler.key().to_owned(),
124                                        handler.serialize(component_data.as_ref()),
125                                    ))
126                                })
127                                .collect()
128                        } else {
129                            error!(
130                                "Entity type {:?} is not registered",
131                                yoleck_managed.type_name
132                            );
133                            Default::default()
134                        }
135                    },
136                })
137        })
138    };
139
140    if matches!(editor_state.get(), YoleckEditorState::EditorActive) {
141        enum LevelManagementAction {
142            DoNothing,
143            ClearLevel,
144            LoadLevel { filename: String },
145            SaveExisting { filename: String },
146        }
147
148        let mut level_management_action = LevelManagementAction::DoNothing;
149
150        let file_button_response = ui.button("File");
151        if file_button_response.clicked() {
152            *file_popup_open = !*file_popup_open;
153        }
154
155        if *file_popup_open {
156            let button_rect = file_button_response.rect;
157            let area_response = egui::Area::new(egui::Id::new("file_popup_area"))
158                .order(egui::Order::Foreground)
159                .fixed_pos(egui::pos2(button_rect.left(), button_rect.bottom() + 2.0))
160                .show(ui.ctx(), |ui| {
161                    egui::Frame::popup(ui.style()).show(ui, |ui| {
162                        ui.set_min_width(400.0);
163                        ui.set_max_width(600.0);
164
165                        let mut path_str = levels_directory.0.to_string_lossy().to_string();
166                        ui.horizontal(|ui| {
167                            ui.label("Levels Directory:");
168                            if ui.text_edit_singleline(&mut path_str).lost_focus() {
169                                *should_list_files = true;
170                            }
171                        });
172                        levels_directory.0 = path_str.into();
173
174                        let mk_files_index = || levels_directory.0.join("index.yoli");
175
176                        let save_index = |loaded_files_index: &[YoleckLevelIndexEntry]| {
177                            let index_file = mk_files_index();
178                            match fs::File::create(&index_file) {
179                                Ok(fd) => {
180                                    let index =
181                                        YoleckLevelIndex::new(loaded_files_index.iter().cloned());
182                                    serde_json::to_writer(fd, &index).unwrap();
183                                }
184                                Err(err) => {
185                                    warn!("Cannot open {:?} - {}", index_file, err);
186                                }
187                            }
188                        };
189
190                        if *should_list_files {
191                            *should_list_files = false;
192
193                            let editable_levels_update_result = fs::read_dir(&levels_directory.0)
194                                .and_then(|files| {
195                                    editable_levels.levels = files
196                                        .filter_map(|file| {
197                                            let file = match file {
198                                                Ok(file) => file,
199                                                Err(err) => return Some(Err(err)),
200                                            };
201                                            if file.path().extension()
202                                                != Some(std::ffi::OsStr::new(EXTENSION_WITHOUT_DOT))
203                                            {
204                                                return None;
205                                            }
206                                            Some(Ok(file.file_name().to_string_lossy().into()))
207                                        })
208                                        .collect::<Result<_, _>>()?;
209                                    Ok(())
210                                });
211
212                            *loaded_files_index = editable_levels_update_result.and_then(|()| {
213                                let index_file = mk_files_index();
214                                let mut files_index: Vec<YoleckLevelIndexEntry> =
215                                    match fs::File::open(&index_file) {
216                                        Ok(fd) => {
217                                            let index: YoleckLevelIndex =
218                                                serde_json::from_reader(fd)?;
219                                            index.iter().cloned().collect()
220                                        }
221                                        Err(err) => {
222                                            warn!("Cannot open {:?} - {}", index_file, err);
223                                            Vec::new()
224                                        }
225                                    };
226                                let mut existing_files: HashSet<String> = files_index
227                                    .iter()
228                                    .map(|file| file.filename.clone())
229                                    .collect();
230                                for filename in editable_levels.names() {
231                                    if !existing_files.remove(filename) {
232                                        files_index.push(YoleckLevelIndexEntry {
233                                            filename: filename.to_owned(),
234                                        });
235                                    }
236                                }
237                                files_index.retain(|file| !existing_files.contains(&file.filename));
238                                save_index(&files_index);
239                                Ok(files_index)
240                            });
241                        }
242
243                        match &mut *loaded_files_index {
244                            Ok(files) => {
245                                let mut swap_with_previous = None;
246                                egui::ScrollArea::vertical()
247                                    .max_height(200.0)
248                                    .show(ui, |ui| {
249                                        for (index, file) in files.iter().enumerate() {
250                                            let is_selected =
251                                                if let SelectedLevelFile::Existing(selected_name) =
252                                                    &*selected_level_file
253                                                {
254                                                    *selected_name == file.filename
255                                                } else {
256                                                    false
257                                                };
258                                            ui.horizontal(|ui| {
259                                                if ui
260                                                    .add_enabled(0 < index, egui::Button::new("^"))
261                                                    .clicked()
262                                                {
263                                                    swap_with_previous = Some(index);
264                                                }
265                                                if ui
266                                                    .add_enabled(
267                                                        index < files.len() - 1,
268                                                        egui::Button::new("v"),
269                                                    )
270                                                    .clicked()
271                                                {
272                                                    swap_with_previous = Some(index + 1);
273                                                }
274                                                let yoleck = yoleck.as_mut();
275                                                if ui
276                                                    .selectable_label(is_selected, &file.filename)
277                                                    .clicked()
278                                                {
279                                                    #[allow(clippy::collapsible_else_if)]
280                                                    if !is_selected && !yoleck.level_needs_saving {
281                                                        *selected_level_file =
282                                                            SelectedLevelFile::Existing(
283                                                                file.filename.clone(),
284                                                            );
285                                                        level_management_action =
286                                                            LevelManagementAction::LoadLevel {
287                                                                filename: file.filename.clone(),
288                                                            };
289                                                    }
290                                                }
291                                            });
292                                        }
293                                    });
294                                if let Some(swap_with_previous) = swap_with_previous {
295                                    files.swap(swap_with_previous, swap_with_previous - 1);
296                                    save_index(files);
297                                }
298                                ui.horizontal(|ui| {
299                                    #[allow(clippy::collapsible_else_if)]
300                                    match &mut *selected_level_file {
301                                        SelectedLevelFile::Unsaved(file_name) => {
302                                            ui.text_edit_singleline(file_name);
303                                            let button = ui.add_enabled(
304                                                !file_name.is_empty(),
305                                                egui::Button::new("Create"),
306                                            );
307                                            if button.clicked() {
308                                                if !file_name.ends_with(EXTENSION) {
309                                                    file_name.push_str(EXTENSION);
310                                                }
311                                                let mut file_path = levels_directory.0.clone();
312                                                file_path.push(&file_name);
313                                                match fs::OpenOptions::new()
314                                                    .write(true)
315                                                    .create_new(true)
316                                                    .open(&file_path)
317                                                {
318                                                    Ok(fd) => {
319                                                        info!(
320                                                            "Saving current new level to {:?}",
321                                                            file_path
322                                                        );
323                                                        serde_json::to_writer(
324                                                            fd,
325                                                            &gen_raw_level_file(),
326                                                        )
327                                                        .unwrap();
328                                                        *selected_level_file =
329                                                            SelectedLevelFile::Existing(
330                                                                file_name.to_owned(),
331                                                            );
332                                                        *should_list_files = true;
333                                                        yoleck.level_needs_saving = false;
334                                                    }
335                                                    Err(err) => {
336                                                        warn!(
337                                                            "Cannot open {:?} - {}",
338                                                            file_path, err
339                                                        );
340                                                    }
341                                                }
342                                            }
343                                        }
344                                        SelectedLevelFile::Existing(_) => {
345                                            let button = ui.add_enabled(
346                                                !yoleck.level_needs_saving,
347                                                egui::Button::new("New Level"),
348                                            );
349                                            if button.clicked() {
350                                                level_management_action =
351                                                    LevelManagementAction::ClearLevel;
352                                                *selected_level_file =
353                                                    SelectedLevelFile::Unsaved(String::new());
354                                                yoleck.level_being_edited = commands
355                                                    .spawn((YoleckLevelInEditor, YoleckKeepLevel))
356                                                    .id();
357                                            }
358                                        }
359                                    }
360                                });
361                            }
362                            Err(err) => {
363                                ui.label(format!("Cannot read: {err}"));
364                            }
365                        }
366                    });
367                });
368
369            if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
370                *file_popup_open = false;
371            }
372
373            if ui.input(|i| i.pointer.primary_clicked())
374                && let Some(pos) = ui.input(|i| i.pointer.interact_pos())
375                && !area_response.response.rect.contains(pos)
376                && !button_rect.contains(pos)
377            {
378                *file_popup_open = false;
379            }
380        }
381
382        match selected_level_file {
383            SelectedLevelFile::Unsaved(_) => {
384                ui.add_enabled_ui(yoleck.level_needs_saving, |ui| {
385                    if ui.button("Wipe Level").clicked() {
386                        level_management_action = LevelManagementAction::ClearLevel;
387                    }
388                });
389            }
390            SelectedLevelFile::Existing(filename) => {
391                ui.label(filename.as_str());
392                ui.add_enabled_ui(yoleck.level_needs_saving, |ui| {
393                    if ui.button("SAVE").clicked() {
394                        level_management_action = LevelManagementAction::SaveExisting {
395                            filename: filename.clone(),
396                        }
397                    }
398                    if ui.button("REVERT").clicked() {
399                        level_management_action = LevelManagementAction::LoadLevel {
400                            filename: filename.clone(),
401                        };
402                    }
403                });
404            }
405        }
406
407        match level_management_action {
408            LevelManagementAction::DoNothing => {}
409            LevelManagementAction::ClearLevel => {
410                for level_entity in keep_levels_query.iter() {
411                    commands.entity(level_entity).despawn();
412                }
413                for knob_entity in knobs_cache.drain() {
414                    commands.entity(knob_entity).despawn();
415                }
416
417                yoleck.level_needs_saving = false;
418            }
419            LevelManagementAction::LoadLevel { filename } => {
420                // Clear the level before loading
421                for level_entity in keep_levels_query.iter() {
422                    commands.entity(level_entity).despawn();
423                }
424                for knob_entity in knobs_cache.drain() {
425                    commands.entity(knob_entity).despawn();
426                }
427
428                yoleck.level_needs_saving = false;
429
430                let fd = fs::File::open(levels_directory.0.join(&filename)).unwrap();
431                let level: serde_json::Value = serde_json::from_reader(fd).unwrap();
432                match upgrade_level_file(level) {
433                    Ok(level) => {
434                        let level: YoleckRawLevel = serde_json::from_value(level).unwrap();
435                        let level_asset_handle = level_assets.add(level);
436                        yoleck.level_being_edited = commands
437                            .spawn((YoleckLevelInEditor, YoleckLoadLevel(level_asset_handle)))
438                            .id();
439                    }
440                    Err(err) => {
441                        warn!("Cannot upgrade {:?} - {}", filename, err);
442                    }
443                }
444            }
445            LevelManagementAction::SaveExisting { filename } => {
446                let file_path = levels_directory.0.join(filename);
447                info!("Saving current level to {:?}", file_path);
448                let fd = fs::OpenOptions::new()
449                    .write(true)
450                    .create(false)
451                    .truncate(true)
452                    .open(file_path)?;
453                serde_json::to_writer(fd, &gen_raw_level_file())?;
454                yoleck.level_needs_saving = false;
455            }
456        }
457    }
458
459    Ok(())
460}
461
462/// The UI part for Playtest buttons in the top panel.
463#[allow(clippy::too_many_arguments)]
464pub fn playtest_buttons_section(
465    mut ui: ResMut<YoleckPanelUi>,
466    mut commands: Commands,
467    mut yoleck: ResMut<YoleckState>,
468    mut playtest_level: ResMut<YoleckPlaytestLevel>,
469    construction_specs: Res<YoleckEntityConstructionSpecs>,
470    yoleck_managed_query: Query<(&YoleckManaged, Option<&YoleckEntityUuid>)>,
471    keep_levels_query: Query<Entity, With<YoleckKeepLevel>>,
472    mut next_editor_state: ResMut<NextState<YoleckEditorState>>,
473    mut knobs_cache: ResMut<YoleckKnobsCache>,
474    mut level_assets: ResMut<Assets<YoleckRawLevel>>,
475    entity_upgrading: Option<Res<YoleckEntityUpgrading>>,
476) -> Result {
477    let gen_raw_level_file = || {
478        let app_format_version = if let Some(entity_upgrading) = &entity_upgrading {
479            entity_upgrading.app_format_version
480        } else {
481            0
482        };
483        YoleckRawLevel::new(app_format_version, {
484            yoleck_managed_query
485                .iter()
486                .map(|(yoleck_managed, entity_uuid)| YoleckRawEntry {
487                    header: YoleckEntryHeader {
488                        type_name: yoleck_managed.type_name.clone(),
489                        name: yoleck_managed.name.clone(),
490                        uuid: entity_uuid.map(|entity_uuid| entity_uuid.get()),
491                    },
492                    data: {
493                        if let Some(entity_type_info) =
494                            construction_specs.get_entity_type_info(&yoleck_managed.type_name)
495                        {
496                            entity_type_info
497                                .components
498                                .iter()
499                                .filter_map(|component| {
500                                    let component_data =
501                                        yoleck_managed.components_data.get(component)?;
502                                    let handler = &construction_specs.component_handlers[component];
503                                    Some((
504                                        handler.key().to_owned(),
505                                        handler.serialize(component_data.as_ref()),
506                                    ))
507                                })
508                                .collect()
509                        } else {
510                            error!(
511                                "Entity type {:?} is not registered",
512                                yoleck_managed.type_name
513                            );
514                            Default::default()
515                        }
516                    },
517                })
518        })
519    };
520
521    let mut clear_level = |commands: &mut Commands| {
522        for level_entity in keep_levels_query.iter() {
523            commands.entity(level_entity).despawn();
524        }
525        for knob_entity in knobs_cache.drain() {
526            commands.entity(knob_entity).despawn();
527        }
528    };
529
530    if let Some(level) = &playtest_level.0 {
531        let finish_playtest_response = ui.button("Finish Playtest");
532        if ui.button("Restart Playtest").clicked() {
533            clear_level(&mut commands);
534            let level_asset_handle = level_assets.add(level.clone());
535            yoleck.level_being_edited = commands
536                .spawn((YoleckLevelInPlaytest, YoleckLoadLevel(level_asset_handle)))
537                .id();
538        }
539        if finish_playtest_response.clicked() {
540            clear_level(&mut commands);
541            next_editor_state.set(YoleckEditorState::EditorActive);
542            let level_asset_handle = level_assets.add(level.clone());
543            yoleck.level_being_edited = commands
544                .spawn((YoleckLevelInEditor, YoleckLoadLevel(level_asset_handle)))
545                .id();
546            playtest_level.0 = None;
547        }
548    } else if ui.button("Playtest").clicked() {
549        let level = gen_raw_level_file();
550        clear_level(&mut commands);
551        next_editor_state.set(YoleckEditorState::GameActive);
552        let level_asset_handle = level_assets.add(level.clone());
553        yoleck.level_being_edited = commands
554            .spawn((YoleckLevelInPlaytest, YoleckLoadLevel(level_asset_handle)))
555            .id();
556        playtest_level.0 = Some(level);
557    }
558
559    ui.separator();
560    Ok(())
561}