use bevy::input_focus::InputFocus;
use bevy::prelude::*;
use bevy::ui_widgets::observe;
use jackdaw_feathers::{
button::{self, ButtonProps, ButtonVariant},
combobox::{self, ComboBoxChangeEvent},
text_edit::{
self, TextEditCommitEvent, TextEditDragging, TextEditProps, TextEditVariant,
TextEditWrapper, TextInputBuffer, TextInputQueue, format_numeric_value,
set_text_input_value,
},
tokens,
};
use super::{TerrainBrushSettings, TerrainDirtyChunks, TerrainEditMode, sculpt::SetTerrainHeights};
use crate::commands::CommandHistory;
use crate::selection::Selection;
pub(super) fn plugin(app: &mut App) {
app.init_resource::<TerrainGenerateState>()
.add_systems(
Update,
(update_terrain_inspector, sync_brush_fields).run_if(in_state(crate::AppState::Editor)),
)
.add_observer(on_generate_clicked)
.add_observer(on_erode_clicked)
.add_observer(on_terrain_text_commit);
}
#[derive(Event)]
struct GenerateClicked;
#[derive(Event)]
struct ErodeClicked;
#[derive(Resource, Default)]
pub struct TerrainGenerateState {
pub settings: jackdaw_terrain::GenerateSettings,
pub erosion: jackdaw_terrain::ErosionParams,
}
#[derive(Component)]
pub struct TerrainInspectorContainer;
pub fn spawn_terrain_inspector_container(commands: &mut Commands, parent: Entity) {
commands.spawn((
TerrainInspectorContainer,
Node {
flex_direction: FlexDirection::Column,
width: Val::Percent(100.0),
row_gap: px(tokens::SPACING_SM),
..Default::default()
},
ChildOf(parent),
));
}
#[derive(Default)]
struct InspectorState {
terrain_entity: Option<Entity>,
edit_mode_is_sculpt: bool,
}
#[derive(Component, Clone, Copy)]
enum BrushField {
Radius,
Strength,
Falloff,
}
#[derive(Component, Clone, Copy)]
enum GenField {
Seed,
Frequency,
Octaves,
Lacunarity,
Persistence,
Amplitude,
Offset,
}
#[derive(Component, Clone, Copy)]
enum ErosionField {
Iterations,
ErosionRadius,
Inertia,
Capacity,
Deposition,
Erosion,
Evaporation,
}
fn update_terrain_inspector(
mut commands: Commands,
selection: Res<Selection>,
edit_mode: Res<TerrainEditMode>,
terrains: Query<(), With<jackdaw_jsn::Terrain>>,
container_query: Query<(Entity, Option<&Children>), With<TerrainInspectorContainer>>,
mut local_state: Local<InspectorState>,
brush_settings: Res<TerrainBrushSettings>,
gen_state: Res<TerrainGenerateState>,
icon_font: Res<jackdaw_feathers::icons::IconFont>,
) {
let terrain_entity = selection.primary().filter(|&e| terrains.contains(e));
let is_sculpt = matches!(*edit_mode, TerrainEditMode::Sculpt(_));
let changed = local_state.terrain_entity != terrain_entity
|| local_state.edit_mode_is_sculpt != is_sculpt
|| (terrain_entity.is_some() && edit_mode.is_changed());
if !changed {
return;
}
local_state.terrain_entity = terrain_entity;
local_state.edit_mode_is_sculpt = is_sculpt;
let container = if let Ok((entity, children)) = container_query.single() {
if let Some(children) = children {
for child in children.iter() {
commands.entity(child).despawn();
}
}
entity
} else {
return;
};
let Some(_terrain_entity) = terrain_entity else {
return;
};
if is_sculpt {
let (_section, body) = jackdaw_feathers::collapsible::collapsible_section(
&mut commands,
"Sculpt Brush",
&icon_font.0,
container,
);
spawn_labeled_field(
&mut commands,
body,
"Radius",
"Area of effect for the brush",
brush_settings.radius as f64,
BrushField::Radius,
);
spawn_labeled_field(
&mut commands,
body,
"Strength",
"How quickly the brush modifies terrain",
brush_settings.strength as f64,
BrushField::Strength,
);
spawn_labeled_field(
&mut commands,
body,
"Falloff",
"Brush edge softness (1=linear, 2=smooth)",
brush_settings.falloff as f64,
BrushField::Falloff,
);
}
let (_section, body) = jackdaw_feathers::collapsible::collapsible_section(
&mut commands,
"Terrain Generation",
&icon_font.0,
container,
);
let noise_options: Vec<String> = jackdaw_terrain::NoiseType::ALL
.iter()
.map(|n| n.label().to_string())
.collect();
let noise_row = commands
.spawn((
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: px(tokens::SPACING_XS),
width: Val::Percent(100.0),
..Default::default()
},
ChildOf(body),
))
.id();
commands.spawn((
Text::new("Noise Type"),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
min_width: px(80.0),
flex_shrink: 0.0,
..Default::default()
},
ChildOf(noise_row),
));
commands
.spawn((
combobox::combobox_with_selected(noise_options, gen_state.settings.noise_type.index()),
ChildOf(noise_row),
))
.observe(
|event: On<ComboBoxChangeEvent>, mut gen_state: ResMut<TerrainGenerateState>| {
gen_state.settings.noise_type =
jackdaw_terrain::NoiseType::from_index(event.selected);
},
);
spawn_gen_field(
&mut commands,
body,
"Seed",
"Same seed always produces the same terrain",
gen_state.settings.seed as f64,
GenField::Seed,
);
spawn_gen_field(
&mut commands,
body,
"Frequency",
"Lower = broader features, higher = finer detail",
gen_state.settings.frequency,
GenField::Frequency,
);
spawn_gen_field(
&mut commands,
body,
"Octaves",
"Layers of noise stacked together. More = finer detail",
gen_state.settings.octaves as f64,
GenField::Octaves,
);
spawn_gen_field(
&mut commands,
body,
"Lacunarity",
"How much each octave's frequency increases",
gen_state.settings.lacunarity,
GenField::Lacunarity,
);
spawn_gen_field(
&mut commands,
body,
"Persistence",
"How much each octave contributes. Lower = subtler",
gen_state.settings.persistence,
GenField::Persistence,
);
spawn_gen_field(
&mut commands,
body,
"Amplitude",
"Overall height scale of the generated terrain",
gen_state.settings.amplitude as f64,
GenField::Amplitude,
);
spawn_gen_field(
&mut commands,
body,
"Offset",
"Vertical offset added after generation",
gen_state.settings.offset as f64,
GenField::Offset,
);
commands.spawn((
button::button(ButtonProps::new("Generate").with_variant(ButtonVariant::Primary)),
ChildOf(body),
observe(|_: On<Pointer<Click>>, mut commands: Commands| {
commands.trigger(GenerateClicked);
}),
));
let (_section, ebody) = jackdaw_feathers::collapsible::collapsible_section(
&mut commands,
"Hydraulic Erosion",
&icon_font.0,
container,
);
spawn_erosion_field(
&mut commands,
ebody,
"Iterations",
"Number of water droplets simulated",
gen_state.erosion.iterations as f64,
ErosionField::Iterations,
);
spawn_erosion_field(
&mut commands,
ebody,
"Erosion Radius",
"Area of effect for each erosion step",
gen_state.erosion.erosion_radius as f64,
ErosionField::ErosionRadius,
);
spawn_erosion_field(
&mut commands,
ebody,
"Inertia",
"How much a droplet keeps its previous direction",
gen_state.erosion.inertia as f64,
ErosionField::Inertia,
);
spawn_erosion_field(
&mut commands,
ebody,
"Capacity",
"How much sediment water can carry",
gen_state.erosion.capacity as f64,
ErosionField::Capacity,
);
spawn_erosion_field(
&mut commands,
ebody,
"Deposition",
"Rate sediment is dropped when water slows",
gen_state.erosion.deposition as f64,
ErosionField::Deposition,
);
spawn_erosion_field(
&mut commands,
ebody,
"Erosion Rate",
"Rate terrain is dissolved by flowing water",
gen_state.erosion.erosion as f64,
ErosionField::Erosion,
);
spawn_erosion_field(
&mut commands,
ebody,
"Evaporation",
"How quickly water droplets shrink",
gen_state.erosion.evaporation as f64,
ErosionField::Evaporation,
);
commands.spawn((
button::button(ButtonProps::new("Erode").with_variant(ButtonVariant::Primary)),
ChildOf(ebody),
observe(|_: On<Pointer<Click>>, mut commands: Commands| {
commands.trigger(ErodeClicked);
}),
));
}
fn sync_brush_fields(
brush_settings: Res<TerrainBrushSettings>,
input_focus: Res<InputFocus>,
outer_query: Query<(Entity, &BrushField, &Children)>,
wrapper_query: Query<&TextEditWrapper>,
dragging_query: Query<(), With<TextEditDragging>>,
children_query: Query<&Children>,
mut queue_query: Query<(&TextInputBuffer, &mut TextInputQueue)>,
) {
if !brush_settings.is_changed() {
return;
}
for (_outer, field, children) in &outer_query {
let new_val = match field {
BrushField::Radius => brush_settings.radius as f64,
BrushField::Strength => brush_settings.strength as f64,
BrushField::Falloff => brush_settings.falloff as f64,
};
let formatted = format_numeric_value(new_val, TextEditVariant::NumericF32);
let mut found = false;
for child in children.iter() {
if let Ok(wrapper) = wrapper_query.get(child) {
if dragging_query.get(child).is_ok() || input_focus.0 == Some(wrapper.0) {
found = true;
break;
}
if let Ok((buffer, mut queue)) = queue_query.get_mut(wrapper.0) {
let current: String = buffer.get_text();
if current != formatted {
set_text_input_value(&mut queue, formatted.clone());
}
}
found = true;
break;
}
}
if found {
continue;
}
for child in children.iter() {
if let Ok(grandchildren) = children_query.get(child) {
for gc in grandchildren.iter() {
if let Ok(wrapper) = wrapper_query.get(gc) {
if dragging_query.get(gc).is_ok() || input_focus.0 == Some(wrapper.0) {
found = true;
break;
}
if let Ok((buffer, mut queue)) = queue_query.get_mut(wrapper.0) {
let current: String = buffer.get_text();
if current != formatted {
set_text_input_value(&mut queue, formatted.clone());
}
}
found = true;
break;
}
}
if found {
break;
}
}
}
}
}
fn spawn_labeled_field(
commands: &mut Commands,
parent: Entity,
label: &str,
tooltip: &str,
value: f64,
field: BrushField,
) {
let row = commands
.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: px(tokens::SPACING_XS),
width: Val::Percent(100.0),
..Default::default()
},
ChildOf(parent),
))
.id();
commands.spawn((
Text::new(label),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(row),
));
commands.spawn((
Text::new(tooltip),
TextFont {
font_size: 10.0,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(row),
));
commands.spawn((
text_edit::text_edit(
TextEditProps::default()
.numeric_f32()
.with_default_value(value.to_string()),
),
field,
ChildOf(row),
));
}
fn spawn_gen_field(
commands: &mut Commands,
parent: Entity,
label: &str,
tooltip: &str,
value: f64,
field: GenField,
) {
let row = commands
.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: px(tokens::SPACING_XS),
width: Val::Percent(100.0),
..Default::default()
},
ChildOf(parent),
))
.id();
commands.spawn((
Text::new(label),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(row),
));
commands.spawn((
Text::new(tooltip),
TextFont {
font_size: 10.0,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(row),
));
commands.spawn((
text_edit::text_edit(
TextEditProps::default()
.numeric_f32()
.with_default_value(value.to_string()),
),
field,
ChildOf(row),
));
}
fn spawn_erosion_field(
commands: &mut Commands,
parent: Entity,
label: &str,
tooltip: &str,
value: f64,
field: ErosionField,
) {
let row = commands
.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: px(tokens::SPACING_XS),
width: Val::Percent(100.0),
..Default::default()
},
ChildOf(parent),
))
.id();
commands.spawn((
Text::new(label),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(row),
));
commands.spawn((
Text::new(tooltip),
TextFont {
font_size: 10.0,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(row),
));
commands.spawn((
text_edit::text_edit(
TextEditProps::default()
.numeric_f32()
.with_default_value(value.to_string()),
),
field,
ChildOf(row),
));
}
fn on_terrain_text_commit(
event: On<TextEditCommitEvent>,
brush_bindings: Query<&BrushField>,
gen_bindings: Query<&GenField>,
erosion_bindings: Query<&ErosionField>,
child_of_query: Query<&ChildOf>,
mut brush_settings: ResMut<TerrainBrushSettings>,
mut gen_state: ResMut<TerrainGenerateState>,
) {
let value: f64 = event.text.parse().unwrap_or(0.0);
let mut current = event.entity;
for _ in 0..4 {
let Ok(child_of) = child_of_query.get(current) else {
break;
};
let parent = child_of.parent();
if let Ok(&field) = brush_bindings.get(parent) {
match field {
BrushField::Radius => brush_settings.radius = value as f32,
BrushField::Strength => brush_settings.strength = value as f32,
BrushField::Falloff => brush_settings.falloff = value as f32,
}
return;
}
if let Ok(&field) = gen_bindings.get(parent) {
match field {
GenField::Seed => gen_state.settings.seed = value as u32,
GenField::Frequency => gen_state.settings.frequency = value,
GenField::Octaves => gen_state.settings.octaves = value as usize,
GenField::Lacunarity => gen_state.settings.lacunarity = value,
GenField::Persistence => gen_state.settings.persistence = value,
GenField::Amplitude => gen_state.settings.amplitude = value as f32,
GenField::Offset => gen_state.settings.offset = value as f32,
}
return;
}
if let Ok(&field) = erosion_bindings.get(parent) {
match field {
ErosionField::Iterations => gen_state.erosion.iterations = value as u32,
ErosionField::ErosionRadius => gen_state.erosion.erosion_radius = value as u32,
ErosionField::Inertia => gen_state.erosion.inertia = value as f32,
ErosionField::Capacity => gen_state.erosion.capacity = value as f32,
ErosionField::Deposition => gen_state.erosion.deposition = value as f32,
ErosionField::Erosion => gen_state.erosion.erosion = value as f32,
ErosionField::Evaporation => gen_state.erosion.evaporation = value as f32,
}
return;
}
current = parent;
}
}
fn on_generate_clicked(
_trigger: On<GenerateClicked>,
selection: Res<Selection>,
mut terrains: Query<(&mut jackdaw_jsn::Terrain, &mut TerrainDirtyChunks)>,
gen_state: Res<TerrainGenerateState>,
mut history: ResMut<CommandHistory>,
) {
let Some(entity) = selection.primary() else {
return;
};
let Ok((mut terrain, mut dirty)) = terrains.get_mut(entity) else {
return;
};
let old_heights = terrain.heights.clone();
let new_heights = jackdaw_terrain::generate_heightmap(terrain.resolution, &gen_state.settings);
terrain.heights = new_heights.clone();
dirty.rebuild_all = true;
let cmd = SetTerrainHeights {
entity,
old_heights,
new_heights,
label: "Generate Terrain".to_string(),
};
history.undo_stack.push(Box::new(cmd));
history.redo_stack.clear();
}
fn on_erode_clicked(
_trigger: On<ErodeClicked>,
selection: Res<Selection>,
mut terrains: Query<(&mut jackdaw_jsn::Terrain, &mut TerrainDirtyChunks)>,
gen_state: Res<TerrainGenerateState>,
mut history: ResMut<CommandHistory>,
) {
let Some(entity) = selection.primary() else {
return;
};
let Ok((mut terrain, mut dirty)) = terrains.get_mut(entity) else {
return;
};
let old_heights = terrain.heights.clone();
let mut heights = terrain.heights.clone();
jackdaw_terrain::hydraulic_erosion(&mut heights, terrain.resolution, &gen_state.erosion);
terrain.heights = heights.clone();
dirty.rebuild_all = true;
let cmd = SetTerrainHeights {
entity,
old_heights,
new_heights: heights,
label: "Erode Terrain".to_string(),
};
history.undo_stack.push(Box::new(cmd));
history.redo_stack.clear();
}