//! Editor-only Project Settings panel (egui). Data lives in the `settings` crate. use std::path::Path; use bevy::prelude::*; use bevy_egui::egui; use game_hot::sync_project_sun_from_settings; use settings::{ save_project_settings_to_string, sync_sim_tuning, ExposureMode, ProjectSettings, ProjectSettingsChanged, ProjectSettingsIo, }; use crate::project::collaboration::{ file_status_indicator_ui, publish_authored_file, CollaborationState, FileSnapshot, FileWriteIntent, }; use crate::scene_io::SceneIo; /// Whether the Project Settings window is visible (editor UI state only). #[derive(Resource, Debug, Default)] pub struct ProjectSettingsPanel { pub open: bool, pub draft: Option, disk_snapshot: Option, save_error: Option, } pub struct SettingsUiPlugin; impl Plugin for SettingsUiPlugin { fn build(&self, app: &mut App) { app.init_resource::().add_systems( PostUpdate, apply_project_settings_changes .after(crate::scene_view::sync_editor_camera_viewport_target), ); } } pub fn open_project_settings_panel(panel: &mut ProjectSettingsPanel) { panel.open = true; panel.draft = None; } pub fn project_settings_window( world: &mut World, ctx: &egui::Context, panel: &mut ProjectSettingsPanel, ) { if !panel.open { panel.draft = None; panel.disk_snapshot = None; panel.save_error = None; return; } if panel.draft.is_none() { panel.draft = Some(world.resource::().clone()); let io = world.resource::(); panel.disk_snapshot = Some( io.loaded_source .as_deref() .map(|source| { FileSnapshot::from_loaded_bytes(Path::new(&io.path), source.as_bytes()) }) .unwrap_or_else(FileSnapshot::missing), ); panel.save_error = None; } let mut open = panel.open; egui::Window::new("Project Settings") .open(&mut open) .default_width(420.0) .show(ctx, |ui| { let ProjectSettingsPanel { draft: Some(draft), disk_snapshot, save_error, .. } = panel else { return; }; let io_path = world.resource::().path.clone(); let mut io_dirty = world.resource::().dirty; draw_project_settings_form( world, ui, draft, &io_path, &mut io_dirty, disk_snapshot, save_error, ); world.resource_mut::().dirty = io_dirty; }); panel.open = open; if !open { panel.draft = None; panel.disk_snapshot = None; panel.save_error = None; } } fn draw_project_settings_form( world: &mut World, ui: &mut egui::Ui, draft: &mut ProjectSettings, io_path: &str, io_dirty: &mut bool, disk_snapshot: &mut Option, save_error: &mut Option, ) { let caps = world .get_resource::() .cloned() .unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.small(egui::RichText::new(io_path).monospace()); if let Some(state) = world.get_resource::() { let status = state.file_status(Path::new(io_path)); let _ = file_status_indicator_ui(ui, &status, Path::new(io_path)); } }); if let Some(error) = save_error.as_deref() { ui.colored_label(crate::ui::theme::ERROR, error); } ui.separator(); ui.collapsing("Global Illumination", |ui| { egui::ComboBox::from_label("GiMode") .selected_text(format!("{:?}", draft.rendering.gi_mode)) .show_ui(ui, |ui| { for mode in [ settings::GiMode::Auto, settings::GiMode::Forward, settings::GiMode::Solari, ] { if ui .selectable_value(&mut draft.rendering.gi_mode, mode, format!("{mode:?}")) .clicked() {} } }); ui.small("Auto uses Solari when RT GPU support is available; otherwise Forward PBR."); let status = match (draft.rendering.gi_mode, caps.rt_supported) { (settings::GiMode::Forward, _) => "Forward PBR", (settings::GiMode::Solari, true) => "Solari active", (settings::GiMode::Solari, false) => "Solari requested — will fall back (no RT)", (settings::GiMode::Auto, true) => "Auto → Solari when playing", (settings::GiMode::Auto, false) => "Auto → Forward fallback (no RT)", }; ui.label(format!("Status: {status}")); ui.small("Open Window → Rendering for resolved active-camera stack."); }); ui.collapsing("Lighting", |ui| { ui.label("Ambient"); ui.horizontal(|ui| { let mut rgb = [ draft.rendering.ambient_color[0], draft.rendering.ambient_color[1], draft.rendering.ambient_color[2], ]; if ui.color_edit_button_rgb(&mut rgb).changed() { draft.rendering.ambient_color = rgb; } }); ui.add( egui::Slider::new(&mut draft.rendering.ambient_brightness, 0.0..=200.0) .text("Ambient brightness"), ); ui.add( egui::Slider::new(&mut draft.rendering.sun_illuminance, 1_000.0..=150_000.0) .text("Sun illuminance (lux)"), ); ui.add( egui::Slider::new(&mut draft.rendering.shadow_cascades, 1..=4).text("Shadow cascades"), ); ui.add( egui::Slider::new(&mut draft.rendering.shadow_first_cascade, 1.0..=20.0) .text("First cascade (m)"), ); ui.add( egui::Slider::new(&mut draft.rendering.shadow_max_distance, 50.0..=500.0) .text("Shadow max distance"), ); }); ui.collapsing("Post Process", |ui| { ui.checkbox(&mut draft.rendering.hdr, "HDR"); ui.small("The viewport uses HDR when Lit mode is active."); ui.horizontal(|ui| { ui.label("Exposure"); if ui .selectable_label( draft.rendering.exposure_mode == ExposureMode::Manual, "Manual", ) .clicked() { draft.rendering.exposure_mode = ExposureMode::Manual; } if ui .selectable_label(draft.rendering.exposure_mode == ExposureMode::Auto, "Auto") .clicked() { draft.rendering.exposure_mode = ExposureMode::Auto; } }); let manual = draft.rendering.exposure_mode == ExposureMode::Manual; if manual { ui.add( egui::Slider::new(&mut draft.rendering.exposure_ev100, 0.0..=20.0) .text("Exposure EV100"), ); } else { ui.add_enabled( false, egui::Slider::new(&mut draft.rendering.exposure_ev100, 0.0..=20.0) .text("Exposure EV100"), ); ui.small("Scene brightness adapts automatically (histogram metering)."); ui.add( egui::Slider::new( &mut draft.rendering.auto_exposure_speed_brighten, 0.1..=10.0, ) .text("Auto brighten (f-stops/s)"), ); ui.add( egui::Slider::new(&mut draft.rendering.auto_exposure_speed_darken, 0.1..=10.0) .text("Auto darken (f-stops/s)"), ); if !draft.rendering.hdr { ui.colored_label( egui::Color32::from_rgb(255, 180, 100), "Auto exposure requires HDR — enable HDR or use Manual.", ); } else { ui.small("Requires GPU compute; see Window → Rendering if unavailable."); } } ui.checkbox(&mut draft.rendering.tonemapping_aces, "Tonemapping ACES"); ui.checkbox(&mut draft.rendering.bloom, "Bloom"); ui.checkbox(&mut draft.rendering.taa, "TAA"); ui.checkbox(&mut draft.rendering.ssao, "SSAO"); ui.checkbox(&mut draft.rendering.atmosphere, "Atmosphere"); ui.label("Fog"); ui.horizontal(|ui| { let mut fog_rgb = draft.rendering.fog_color; if ui.color_edit_button_rgb(&mut fog_rgb).changed() { draft.rendering.fog_color = fog_rgb; } }); ui.add( egui::Slider::new(&mut draft.rendering.fog_density, 0.0..=0.01) .logarithmic(true) .text("Fog density"), ); }); ui.collapsing("Audio Buses", |ui| { let bus_ids: Vec = draft.audio.buses.iter().map(|bus| bus.id.clone()).collect(); let mut remove_bus = None; for (index, bus) in draft.audio.buses.iter_mut().enumerate() { egui::Frame::new() .fill(egui::Color32::from_rgb(24, 26, 29)) .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(53, 57, 63))) .corner_radius(4.0) .inner_margin(8.0) .show(ui, |ui| { ui.horizontal(|ui| { ui.strong(&bus.label); ui.small( egui::RichText::new(format!("[{}]", bus.id)) .color(egui::Color32::from_rgb(139, 146, 156)), ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let required = matches!( bus.id.as_str(), settings::AUDIO_BUS_MASTER_ID | settings::AUDIO_BUS_MUSIC_ID | settings::AUDIO_BUS_SFX_ID ); if ui .add_enabled(!required, egui::Button::new("Remove")) .clicked() { remove_bus = Some(index); } }); }); ui.horizontal_wrapped(|ui| { ui.label("Name"); ui.add_sized([140.0, 20.0], egui::TextEdit::singleline(&mut bus.label)); ui.checkbox(&mut bus.muted, "Mute"); }); ui.add( egui::Slider::new( &mut bus.gain_db, settings::AUDIO_GAIN_DB_MIN..=settings::AUDIO_GAIN_DB_MAX, ) .text("Gain") .suffix(" dB"), ); if bus.id != settings::AUDIO_BUS_MASTER_ID { let parent_label = bus .parent .as_deref() .and_then(|parent| bus_ids.iter().find(|id| id.as_str() == parent)) .map(String::as_str) .unwrap_or("None"); egui::ComboBox::from_id_salt(("audio_bus_parent", index)) .selected_text(parent_label) .show_ui(ui, |ui| { for candidate in &bus_ids { if candidate != &bus.id { ui.selectable_value( &mut bus.parent, Some(candidate.clone()), candidate, ); } } }); } }); ui.add_space(4.0); } if let Some(index) = remove_bus { draft.audio.buses.remove(index); } if ui.button("Add Bus").clicked() { let mut suffix = 1usize; let id = loop { let candidate = format!("bus_{suffix}"); if draft.audio.bus(&candidate).is_none() { break candidate; } suffix += 1; }; draft.audio.buses.push(settings::AudioBusSettings::new( id, "New Bus", Some(settings::AUDIO_BUS_MASTER_ID), )); } if let Err(error) = draft.audio.validate() { ui.colored_label(egui::Color32::from_rgb(235, 105, 105), error.to_string()); } }); ui.separator(); ui.heading("Physics"); ui.add(egui::Slider::new(&mut draft.physics.walk_speed, 1.0..=15.0).text("Walk speed")); ui.add(egui::Slider::new(&mut draft.physics.run_speed, 1.0..=20.0).text("Run speed")); ui.add(egui::Slider::new(&mut draft.physics.gravity, 5.0..=40.0).text("Gravity")); ui.add(egui::Slider::new(&mut draft.physics.jump_speed, 3.0..=15.0).text("Jump speed")); ui.separator(); ui.heading("Input"); ui.add( egui::Slider::new(&mut draft.input.yaw_sensitivity, 0.0005..=0.01).text("Yaw sensitivity"), ); ui.add( egui::Slider::new(&mut draft.input.pitch_sensitivity, 0.0005..=0.01) .text("Pitch sensitivity"), ); ui.add( egui::Slider::new(&mut draft.input.editor_fly_speed, 2.0..=40.0).text("Editor fly speed"), ); ui.separator(); let settings_valid = draft.audio.validate().is_ok(); if ui .add_enabled(settings_valid, egui::Button::new("Apply")) .clicked() { commit_project_settings(world, draft); *io_dirty = true; } if ui .add_enabled(settings_valid, egui::Button::new("Save to disk")) .clicked() { commit_project_settings(world, draft); *io_dirty = true; let settings = world.resource::().clone(); let Some(expected) = disk_snapshot.clone() else { let error = "Project settings disk baseline is unavailable; reload before saving"; *save_error = Some(error.into()); world.resource_mut::().set_status(error); return; }; match persist_project_settings(world, &settings, &expected) { Ok((snapshot, source)) => { let mut io = world.resource_mut::(); io.dirty = false; io.loaded_source = Some(source); *disk_snapshot = Some(snapshot); *save_error = None; *io_dirty = false; world .resource_mut::() .set_status(format!("Saved project settings to {io_path}")); } Err(error) => { warn!("Save project settings failed: {error}"); *save_error = Some(error.clone()); world .resource_mut::() .set_status(format!("Save project settings failed: {error}")); } } } if ui.button("Revert from disk").clicked() { match read_project_settings_document(Path::new(io_path)) { Ok((settings, source, snapshot)) => { *draft = settings; commit_project_settings(world, draft); let mut io = world.resource_mut::(); io.dirty = false; io.loaded_source = Some(source); *disk_snapshot = Some(snapshot); *save_error = None; *io_dirty = false; } Err(error) => { *save_error = Some(error.clone()); world .resource_mut::() .set_status(format!("Revert project settings failed: {error}")); } } } if *io_dirty { ui.label(egui::RichText::new("Unsaved changes").color(egui::Color32::YELLOW)); } } fn persist_project_settings( world: &mut World, settings: &ProjectSettings, expected: &FileSnapshot, ) -> Result<(FileSnapshot, String), String> { let path = world.resource::().path.clone(); let source = save_project_settings_to_string(settings).map_err(|error| error.to_string())?; let snapshot = publish_authored_file( world, Path::new(&path), source.as_bytes(), expected, FileWriteIntent::ProjectSettings, )?; Ok((snapshot, source)) } fn read_project_settings_document( path: &Path, ) -> Result<(ProjectSettings, String, FileSnapshot), String> { let source = std::fs::read_to_string(path) .map_err(|error| format!("could not read {}: {error}", path.display()))?; let settings: ProjectSettings = ron::from_str(&source) .map_err(|error| format!("invalid project settings in {}: {error}", path.display()))?; let snapshot = FileSnapshot::from_loaded_bytes(path, source.as_bytes()); Ok((settings, source, snapshot)) } pub(crate) fn reload_project_settings_after_file_conflict( world: &mut World, path: &Path, ) -> Result { let active_path = world.resource::().path.clone(); if Path::new(&active_path) != path { return Err("the project settings conflict no longer matches the active manifest".into()); } let (settings, source, snapshot) = read_project_settings_document(path)?; commit_project_settings(world, &settings); { let mut io = world.resource_mut::(); io.dirty = false; io.loaded_source = Some(source); } { let mut panel = world.resource_mut::(); panel.draft = Some(settings); panel.disk_snapshot = Some(snapshot); panel.save_error = None; } Ok(format!("Reloaded project settings from {}", path.display())) } pub(crate) fn project_settings_conflict_copy_saved(world: &mut World) { world.resource_mut::().save_error = None; } fn commit_project_settings(world: &mut World, draft: &ProjectSettings) { let previous = world.resource::().rendering.gi_mode; *world.resource_mut::() = draft.clone(); if previous != draft.rendering.gi_mode { info!( "Project settings GI mode changed: {:?} -> {:?}", previous, draft.rendering.gi_mode ); } else { debug!( "Project settings applied: gi_mode={:?}", draft.rendering.gi_mode ); } world.write_message(ProjectSettingsChanged); } fn apply_project_settings_changes( mut reader: MessageReader, mut commands: Commands, settings: Res, tuning: ResMut, scene_suns: Query< ( &shared::LightDesc, Option<&shared::AuthoringComponentStates>, Option<&shared::InspectorOrder>, ), With, >, project_suns: Query<(&mut DirectionalLight, &mut Visibility), With>, ) { if reader.read().next().is_none() { return; } sync_sim_tuning(&settings, tuning); { let ambient = settings.rendering.clone(); commands.insert_resource(GlobalAmbientLight { color: Color::srgb( ambient.ambient_color[0], ambient.ambient_color[1], ambient.ambient_color[2], ), brightness: ambient.ambient_brightness, ..default() }); } sync_project_sun_from_settings(settings, scene_suns, project_suns); // Full stack refresh runs next frame via `render_view::sync_project_render_view` // when `ProjectSettings` changes. } #[cfg(test)] mod tests { use super::*; #[test] fn project_settings_save_refuses_an_external_revision() { let root = std::env::temp_dir().join(format!( "blacksite-project-settings-external-write-{}", uuid::Uuid::new_v4() )); let path = root.join("assets/project.ron"); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); let loaded = save_project_settings_to_string(&ProjectSettings::default()).unwrap(); std::fs::write(&path, &loaded).unwrap(); let expected = FileSnapshot::from_loaded_bytes(&path, loaded.as_bytes()); let mut world = World::new(); world.insert_resource(ProjectSettingsIo { path: path.to_string_lossy().into_owned(), loaded_source: Some(loaded), ..Default::default() }); world.insert_resource(SceneIo::default()); std::fs::write(&path, b"external").unwrap(); let result = persist_project_settings(&mut world, &ProjectSettings::default(), &expected); assert!(result.unwrap_err().contains("changed outside Blacksite")); assert_eq!(std::fs::read(&path).unwrap(), b"external"); std::fs::remove_dir_all(root).unwrap(); } }