584 lines
21 KiB
Rust
584 lines
21 KiB
Rust
//! 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<ProjectSettings>,
|
|
disk_snapshot: Option<FileSnapshot>,
|
|
save_error: Option<String>,
|
|
}
|
|
|
|
pub struct SettingsUiPlugin;
|
|
|
|
impl Plugin for SettingsUiPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<ProjectSettingsPanel>().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::<ProjectSettings>().clone());
|
|
let io = world.resource::<ProjectSettingsIo>();
|
|
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::<ProjectSettingsIo>().path.clone();
|
|
let mut io_dirty = world.resource::<ProjectSettingsIo>().dirty;
|
|
draw_project_settings_form(
|
|
world,
|
|
ui,
|
|
draft,
|
|
&io_path,
|
|
&mut io_dirty,
|
|
disk_snapshot,
|
|
save_error,
|
|
);
|
|
world.resource_mut::<ProjectSettingsIo>().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<FileSnapshot>,
|
|
save_error: &mut Option<String>,
|
|
) {
|
|
let caps = world
|
|
.get_resource::<settings::RenderingCapabilities>()
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
ui.horizontal_wrapped(|ui| {
|
|
ui.small(egui::RichText::new(io_path).monospace());
|
|
if let Some(state) = world.get_resource::<CollaborationState>() {
|
|
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<String> = 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::<ProjectSettings>().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::<SceneIo>().set_status(error);
|
|
return;
|
|
};
|
|
match persist_project_settings(world, &settings, &expected) {
|
|
Ok((snapshot, source)) => {
|
|
let mut io = world.resource_mut::<ProjectSettingsIo>();
|
|
io.dirty = false;
|
|
io.loaded_source = Some(source);
|
|
*disk_snapshot = Some(snapshot);
|
|
*save_error = None;
|
|
*io_dirty = false;
|
|
world
|
|
.resource_mut::<SceneIo>()
|
|
.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::<SceneIo>()
|
|
.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::<ProjectSettingsIo>();
|
|
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::<SceneIo>()
|
|
.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::<ProjectSettingsIo>().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<String, String> {
|
|
let active_path = world.resource::<ProjectSettingsIo>().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::<ProjectSettingsIo>();
|
|
io.dirty = false;
|
|
io.loaded_source = Some(source);
|
|
}
|
|
{
|
|
let mut panel = world.resource_mut::<ProjectSettingsPanel>();
|
|
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::<ProjectSettingsPanel>().save_error = None;
|
|
}
|
|
|
|
fn commit_project_settings(world: &mut World, draft: &ProjectSettings) {
|
|
let previous = world.resource::<ProjectSettings>().rendering.gi_mode;
|
|
*world.resource_mut::<ProjectSettings>() = 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<ProjectSettingsChanged>,
|
|
mut commands: Commands,
|
|
settings: Res<ProjectSettings>,
|
|
tuning: ResMut<settings::SimTuning>,
|
|
scene_suns: Query<
|
|
(
|
|
&shared::LightDesc,
|
|
Option<&shared::AuthoringComponentStates>,
|
|
Option<&shared::InspectorOrder>,
|
|
),
|
|
With<shared::LevelObject>,
|
|
>,
|
|
project_suns: Query<(&mut DirectionalLight, &mut Visibility), With<shared::ProjectSun>>,
|
|
) {
|
|
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();
|
|
}
|
|
}
|