diff --git a/assets/ui/terraform.png b/assets/ui/terraform.png new file mode 100644 index 00000000..8127ca0a --- /dev/null +++ b/assets/ui/terraform.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03b0bad6a90b7c24ad1d707a5186e0bed83be14a8b9082502fc492eb79d9fc2d +size 11167 diff --git a/geom/src/heightmap.rs b/geom/src/heightmap.rs index 58492fc1..ba9a27c5 100644 --- a/geom/src/heightmap.rs +++ b/geom/src/heightmap.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; pub type HeightmapChunkID = (u16, u16); const MIN_HEIGHT: f32 = -40.0; +const MAX_HEIGHT: f32 = 2008.0; #[derive(Clone)] pub struct HeightmapChunk { @@ -36,12 +37,14 @@ impl HeightmapChunk } } + #[inline] pub fn rect(id: HeightmapChunkID) -> AABB { let ll = vec2(id.0 as f32 * SIZE as f32, id.1 as f32 * SIZE as f32); let ur = ll + vec2(SIZE as f32, SIZE as f32); AABB::new(ll, ur) } + #[inline] pub fn id(v: Vec2) -> HeightmapChunkID { let x = v.x / SIZE as f32; let x = x.clamp(0.0, u16::MAX as f32) as u16; @@ -50,6 +53,7 @@ impl HeightmapChunk (x, y) } + #[inline] pub fn bbox(&self, origin: Vec2) -> AABB3 { AABB3::new( vec3(origin.x, origin.y, MIN_HEIGHT), @@ -62,18 +66,21 @@ impl HeightmapChunk } /// assume p is in chunk-space and in-bounds + #[inline] pub fn height_unchecked(&self, p: Vec2) -> f32 { let v = p / SIZE as f32; let v = v * RESOLUTION as f32; self.heights[v.y as usize][v.x as usize] } + #[inline] pub fn height(&self, p: Vec2) -> Option { let v = p / SIZE as f32; let v = v * RESOLUTION as f32; self.heights.get(v.y as usize)?.get(v.x as usize).copied() } + #[inline] pub fn heights(&self) -> &[[f32; RESOLUTION]; RESOLUTION] { &self.heights } @@ -99,6 +106,7 @@ impl Heightmap { } } + #[inline] pub fn bounds(&self) -> AABB { AABB::new( vec2(0.0, 0.0), @@ -106,10 +114,12 @@ impl Heightmap { ) } + #[inline] fn check_valid(&self, id: HeightmapChunkID) -> bool { id.0 < self.w && id.1 < self.h } + #[inline] pub fn set_chunk(&mut self, id: HeightmapChunkID, chunk: HeightmapChunk) { if !self.check_valid(id) { return; @@ -117,6 +127,7 @@ impl Heightmap { self.chunks[(id.0 + id.1 * self.w) as usize] = chunk; } + #[inline] pub fn get_chunk(&self, id: HeightmapChunkID) -> Option<&HeightmapChunk> { if !self.check_valid(id) { return None; @@ -124,6 +135,59 @@ impl Heightmap { unsafe { Some(self.chunks.get_unchecked((id.0 + id.1 * self.w) as usize)) } } + fn get_chunk_mut( + &mut self, + id: HeightmapChunkID, + ) -> Option<&mut HeightmapChunk> { + if !self.check_valid(id) { + return None; + } + unsafe { + Some( + self.chunks + .get_unchecked_mut((id.0 + id.1 * self.w) as usize), + ) + } + } + + /// Applies a function to every point in the heightmap in the given bounds + pub fn apply(&mut self, bounds: AABB, mut f: impl FnMut(Vec3) -> f32) -> Vec { + let ll = bounds.ll / SIZE as f32; + let ur = bounds.ur / SIZE as f32; + let ll = vec2(ll.x.floor(), ll.y.floor()); + let ur = vec2(ur.x.ceil(), ur.y.ceil()); + + let mut modified = Vec::with_capacity(((ur.x - ll.x) * (ur.y - ll.y)) as usize); + + for x in ll.x as u16..ur.x as u16 { + for y in ll.y as u16..ur.y as u16 { + let id = (x, y); + let Some(chunk) = self.get_chunk_mut(id) else { + continue; + }; + modified.push(id); + let corner = vec2(x as f32, y as f32) * SIZE as f32; + let mut max_height: f32 = 0.0; + for i in 0..RESOLUTION { + for j in 0..RESOLUTION { + let p = corner + vec2(j as f32, i as f32) * Self::CELL_SIZE; + let h = chunk.heights[i][j]; + max_height = max_height.max(h); + if !bounds.contains(p) { + continue; + } + let new_h = f(p.z(h)).clamp(MIN_HEIGHT, MAX_HEIGHT); + chunk.heights[i][j] = new_h; + max_height = max_height.max(new_h); + } + } + chunk.max_height = max_height; + } + } + + modified + } + pub fn chunks( &self, ) -> impl Iterator)> + '_ { @@ -281,7 +345,7 @@ fn binary_search(min: f32, max: f32, mut f: impl FnMut(f32) -> bool) -> f32 { impl Serialize for HeightmapChunk { fn serialize(&self, serializer: S) -> Result { - let mut seq = serializer.serialize_seq(Some(RESOLUTION * RESOLUTION))?; + let mut seq = serializer.serialize_seq(Some(1 + RESOLUTION * RESOLUTION))?; seq.serialize_element(&self.max_height)?; for row in &self.heights { for height in row { @@ -315,8 +379,8 @@ impl<'de, const RESOLUTION: usize> serde::de::Visitor<'de> for HeightmapChunkVis } fn visit_seq>(self, mut seq: A) -> Result { - let len = seq.size_hint().unwrap_or(RESOLUTION * RESOLUTION); - if len != RESOLUTION * RESOLUTION { + let len = seq.size_hint().unwrap_or(1 + RESOLUTION * RESOLUTION); + if len != 1 + RESOLUTION * RESOLUTION { return Err(serde::de::Error::invalid_length(len, &"")); } let max_height = seq diff --git a/native_app/src/game_loop.rs b/native_app/src/game_loop.rs index 5fc50ee4..1123b604 100644 --- a/native_app/src/game_loop.rs +++ b/native_app/src/game_loop.rs @@ -113,14 +113,19 @@ impl engine::framework::State for State { if !ctx.egui.last_mouse_captured { let sim = self.sim.read().unwrap(); let map = sim.map(); - let unproj = self + let ray = self .uiw .read::() - .unproject(ctx.input.mouse.screen, |p| { - map.terrain.height(p).map(|x| x + 0.01) - }); + .camera + .unproj_ray(ctx.input.mouse.screen); + self.uiw.write::().ray = ray; - self.uiw.write::().unprojected = unproj; + if let Some(ray) = ray { + let cast = map.terrain.raycast(ray); + + self.uiw.write::().unprojected = cast.map(|x| x.0); + self.uiw.write::().unprojected_normal = cast.map(|x| x.1); + } } self.uiw.write::().prepare_frame( diff --git a/native_app/src/gui/mod.rs b/native_app/src/gui/mod.rs index 6b575c3d..f6f4344f 100644 --- a/native_app/src/gui/mod.rs +++ b/native_app/src/gui/mod.rs @@ -23,6 +23,7 @@ pub mod roadbuild; pub mod roadeditor; pub mod selectable; pub mod specialbuilding; +pub mod terraforming; pub mod topgui; pub mod windows; pub mod zoneedit; @@ -40,6 +41,7 @@ pub fn run_ui_systems(sim: &Simulation, uiworld: &mut UiWorld) { specialbuilding::specialbuilding(sim, uiworld); addtrain::addtrain(sim, uiworld); zoneedit::zoneedit(sim, uiworld); + terraforming::terraforming(sim, uiworld); // run last so other systems can have the chance to cancel select selectable::selectable(sim, uiworld); @@ -105,7 +107,6 @@ impl Default for InspectedEntity { #[derive(Copy, Clone, Default, Serialize, Deserialize, Eq, PartialEq)] pub enum Tool { - #[default] Hand, RoadbuildStraight, RoadbuildCurved, @@ -114,6 +115,8 @@ pub enum Tool { LotBrush, SpecialBuilding, Train, + #[default] + Terraforming, } impl Tool { diff --git a/native_app/src/gui/terraforming.rs b/native_app/src/gui/terraforming.rs new file mode 100644 index 00000000..ac81ecb4 --- /dev/null +++ b/native_app/src/gui/terraforming.rs @@ -0,0 +1,56 @@ +use super::Tool; +use crate::inputmap::{InputAction, InputMap}; +use crate::rendering::immediate::ImmediateDraw; +use crate::uiworld::UiWorld; +use common::timestep::UP_DT; +use egui_inspect::Inspect; +use geom::LinearColor; +use simulation::engine_interaction::WorldCommand; +use simulation::map::TerraformKind; +use simulation::Simulation; + +#[derive(Inspect)] +pub struct TerraformingResource { + pub kind: TerraformKind, + pub radius: f32, + pub amount: f32, +} + +/// Lot brush tool +/// Allows to build houses on lots +pub fn terraforming(sim: &Simulation, uiworld: &mut UiWorld) { + profiling::scope!("gui::terraforming"); + let res = uiworld.write::(); + let tool = *uiworld.read::(); + let inp = uiworld.read::(); + let mut draw = uiworld.write::(); + let _map = sim.map(); + let commands = &mut *uiworld.commands(); + + if !matches!(tool, Tool::Terraforming) { + return; + } + + let mpos = unwrap_ret!(inp.unprojected); + draw.circle(mpos.up(0.8), res.radius) + .color(LinearColor::GREEN.a(0.1)); + + if inp.act.contains(&InputAction::Select) { + commands.push(WorldCommand::Terraform { + center: mpos.xy(), + radius: res.radius, + amount: res.amount * UP_DT.as_secs_f32(), + kind: res.kind, + }) + } +} + +impl Default for TerraformingResource { + fn default() -> Self { + Self { + kind: TerraformKind::Raise, + radius: 200.0, + amount: 200.0, + } + } +} diff --git a/native_app/src/gui/topgui.rs b/native_app/src/gui/topgui.rs index 634376d6..b7c36f06 100644 --- a/native_app/src/gui/topgui.rs +++ b/native_app/src/gui/topgui.rs @@ -4,6 +4,7 @@ use crate::gui::inspect::inspector; use crate::gui::lotbrush::LotBrushResource; use crate::gui::roadeditor::RoadEditorResource; use crate::gui::specialbuilding::{SpecialBuildKind, SpecialBuildingResource}; +use crate::gui::terraforming::TerraformingResource; use crate::gui::windows::settings::Settings; use crate::gui::windows::GUIWindows; use crate::gui::{ErrorTooltip, PotentialCommands, RoadBuildResource, Tool, UiTextures}; @@ -162,6 +163,7 @@ impl Gui { Roadbuilding, Bulldozer, Train, + Terraforming, } uiworld.check_present(|| Tab::Hand); @@ -190,6 +192,7 @@ impl Gui { ("buildings", Tab::Roadbuilding, Tool::SpecialBuilding), ("bulldozer", Tab::Bulldozer, Tool::Bulldozer), ("traintool", Tab::Train, Tool::Train), + ("terraform", Tab::Terraforming, Tool::Terraforming), ]; Window::new("Toolbox") @@ -511,6 +514,31 @@ impl Gui { }); } + if matches!(*uiworld.read::(), Tab::Terraforming) { + let lbw = 120.0; + Window::new("Terraforming") + .min_width(lbw) + .auto_sized() + .fixed_pos([w - toolbox_w - lbw, h * 0.5 - 30.0]) + .hscroll(false) + .title_bar(true) + .collapsible(false) + .resizable(false) + .show(ui, |ui| { + let mut state = uiworld.write::(); + >::render_mut( + &mut *state, + "Terraforming", + ui, + &InspectArgs { + header: Some(false), + indent_children: Some(false), + ..Default::default() + }, + ); + }); + } + let building_select_w = 200.0; let registry = sim.read::(); let gbuildings = registry.descriptions.values().peekable(); diff --git a/native_app/src/init.rs b/native_app/src/init.rs index 11f64acd..a2268ac5 100644 --- a/native_app/src/init.rs +++ b/native_app/src/init.rs @@ -5,6 +5,7 @@ use crate::gui::lotbrush::LotBrushResource; use crate::gui::roadbuild::RoadBuildResource; use crate::gui::roadeditor::RoadEditorResource; use crate::gui::specialbuilding::SpecialBuildingResource; +use crate::gui::terraforming::TerraformingResource; use crate::gui::windows::debug::{DebugObjs, DebugState, TestFieldProperties}; use crate::gui::windows::settings::Settings; use crate::gui::zoneedit::ZoneEditState; @@ -31,6 +32,7 @@ pub fn init() { register_resource::("lot_brush"); register_resource::("bindings"); + register_resource_noserialize::(); register_resource_noserialize::(); register_resource_noserialize::(); register_resource_noserialize::(); diff --git a/native_app/src/inputmap.rs b/native_app/src/inputmap.rs index b8d16414..0777205d 100644 --- a/native_app/src/inputmap.rs +++ b/native_app/src/inputmap.rs @@ -1,7 +1,7 @@ use common::{FastMap, FastSet}; use engine::ScanCode; use engine::{InputContext, KeyCode, MouseButton}; -use geom::{Vec2, Vec3}; +use geom::{Ray3, Vec2, Vec3}; use std::collections::hash_map::Entry; use std::collections::{BTreeMap, HashSet}; use std::fmt::{Debug, Display, Formatter}; @@ -53,10 +53,19 @@ struct InputTree { #[derive(Default)] pub struct InputMap { + /// Actions that were just pressed this frame pub just_act: FastSet, + /// Actions that are currently pressed pub act: FastSet, + /// Mouse wheel delta pub wheel: f32, + /// Mouse position in world space on the terrain pub unprojected: Option, + + pub unprojected_normal: Option, + /// Ray from camera to mouse + pub ray: Option, + /// Mouse position in screen space pub screen: Vec2, input_tree: InputTree, } diff --git a/simulation/src/engine_interaction.rs b/simulation/src/engine_interaction.rs index ec339091..86cc7de6 100644 --- a/simulation/src/engine_interaction.rs +++ b/simulation/src/engine_interaction.rs @@ -11,7 +11,7 @@ use crate::economy::Government; use crate::map::procgen::{load_parismap, load_testfield}; use crate::map::{ BuildingID, BuildingKind, IntersectionID, LaneID, LanePattern, LanePatternBuilder, LightPolicy, - LotID, Map, MapProject, ProjectKind, RoadID, Terrain, TurnPolicy, Zone, + LotID, Map, MapProject, ProjectKind, RoadID, TerraformKind, Terrain, TurnPolicy, Zone, }; use crate::map_dynamic::{BuildingInfos, ParkingManagement}; use crate::multiplayer::chat::Message; @@ -37,6 +37,12 @@ pub enum WorldCommand { MapRemoveRoad(RoadID), MapRemoveBuilding(BuildingID), MapBuildHouse(LotID), + Terraform { + kind: TerraformKind, + center: Vec2, + radius: f32, + amount: f32, + }, SendMessage { message: Message, }, @@ -335,6 +341,14 @@ impl WorldCommand { .chat .add_message(message.clone()); } + Terraform { + kind, + amount, + center, + radius, + } => { + sim.map_mut().terraform(kind, center, radius, amount); + } } } } diff --git a/simulation/src/init.rs b/simulation/src/init.rs index e7ae9c60..43e678aa 100644 --- a/simulation/src/init.rs +++ b/simulation/src/init.rs @@ -24,7 +24,7 @@ use crate::{ Replay, RunnableSystem, Simulation, SimulationOptions, RNG_SEED, SECONDS_PER_DAY, SECONDS_PER_HOUR, }; -use common::saveload::{Bincode, Encoder}; +use common::saveload::{Bincode, Encoder, JSON}; use serde::de::DeserializeOwned; use serde::Serialize; @@ -75,7 +75,7 @@ pub fn init() { register_resource::("coworld", || CollisionWorld::new(100)); register_resource::("randprovider", || RandProvider::new(RNG_SEED)); register_resource_default::("dispatcher"); - register_resource_default::("replay"); + register_resource_default::("replay"); } pub struct InitFunc { @@ -161,10 +161,13 @@ fn register_resource_noinit()).unwrap()), - load: Box::new(move |uiworld, data| { - if let Ok(res) = E::decode::(&data) { + load: Box::new(move |uiworld, data| match E::decode::(&data) { + Ok(res) => { uiworld.insert(res); } + Err(e) => { + log::error!("Error loading resource {}: {}", name, e); + } }), }); } diff --git a/simulation/src/map/change_detection.rs b/simulation/src/map/change_detection.rs index 109a85f8..517cd65f 100644 --- a/simulation/src/map/change_detection.rs +++ b/simulation/src/map/change_detection.rs @@ -52,15 +52,16 @@ impl MapSubscribers { } pub fn dispatch(&mut self, update_type: UpdateType, p: &impl CanonicalPosition) { - let chunk_id = ChunkID::new(p.canonical_position()); + let chunk_id = SubscriberChunkID::new(p.canonical_position()); self.dispatch_chunk(update_type, chunk_id); } - pub fn dispatch_chunk(&mut self, update_type: UpdateType, chunk_id: SubscriberChunkID) { - let mut me = self.0.lock().unwrap(); - for sub in me.iter_mut() { - sub.dispatch(update_type, chunk_id); - } + pub fn dispatch_chunk( + &mut self, + update_type: UpdateType, + chunk_id: ChunkID, + ) { + self.dispatch_chunks(update_type, chunk_id.convert()); } pub fn dispatch_chunks( diff --git a/simulation/src/map/map.rs b/simulation/src/map/map.rs index 6476251b..4b1f2dc4 100644 --- a/simulation/src/map/map.rs +++ b/simulation/src/map/map.rs @@ -3,7 +3,7 @@ use crate::map::{ Building, BuildingID, BuildingKind, Intersection, IntersectionID, Lane, LaneID, LaneKind, LanePattern, Lot, LotID, LotKind, MapSubscriber, MapSubscribers, ParkingSpotID, ParkingSpots, ProjectFilter, ProjectKind, Road, RoadID, RoadSegmentKind, SpatialMap, SubscriberChunkID, - Terrain, UpdateType, Zone, + TerraformKind, Terrain, UpdateType, Zone, }; use common::descriptions::BuildingGen; use geom::OBB; @@ -178,7 +178,7 @@ impl Map { self.terrain.remove_trees_near(&z.poly, |tree_chunk| { self.subscribers - .dispatch_chunks(UpdateType::Terrain, tree_chunk.convert()) + .dispatch_chunk(UpdateType::Terrain, tree_chunk) }); self.spatial_map.insert(id, z.poly.clone()); @@ -216,7 +216,7 @@ impl Map { self.terrain .remove_trees_near(obb.expand(10.0), |tree_chunk| { self.subscribers - .dispatch_chunks(UpdateType::Terrain, tree_chunk.convert()) + .dispatch_chunk(UpdateType::Terrain, tree_chunk) }); let v = Building::make( @@ -326,6 +326,14 @@ impl Map { } } + pub fn terraform(&mut self, kind: TerraformKind, center: Vec2, radius: f32, amount: f32) { + let modified = self.terrain.terraform(kind, center, radius, amount); + + for id in modified { + self.subscribers.dispatch_chunk(UpdateType::Terrain, id); + } + } + pub fn clear(&mut self) { info!("clear"); let before = std::mem::replace(self, Self::empty()); @@ -546,7 +554,7 @@ impl Map { b.expand(40.0); self.terrain.remove_trees_near(&b, |tree_chunk| { self.subscribers - .dispatch_chunks(UpdateType::Terrain, tree_chunk.convert()) + .dispatch_chunk(UpdateType::Terrain, tree_chunk) }); Some(id) diff --git a/simulation/src/map/terrain.rs b/simulation/src/map/terrain.rs index f11a5f65..06f33dca 100644 --- a/simulation/src/map/terrain.rs +++ b/simulation/src/map/terrain.rs @@ -1,7 +1,7 @@ use crate::map::procgen::heightmap; use crate::map::procgen::heightmap::tree_density; use flat_spatial::Grid; -use geom::{vec2, Intersect, Radians, Vec2, AABB}; +use geom::{vec2, Intersect, Radians, Ray3, Vec2, Vec3, AABB}; use rayon::iter::{IntoParallelIterator, ParallelIterator}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -31,6 +31,13 @@ pub struct Terrain { pub trees: Grid, } +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +pub enum TerraformKind { + Raise, +} + +debug_inspect_impl!(TerraformKind); + defer_serialize!(Terrain, SerializedTerrain); impl Default for Terrain { @@ -111,6 +118,45 @@ impl Terrain { .map(|((x, y), c)| (TerrainChunkID::new_i16(x as i16, y as i16), c)) } + pub fn raycast(&self, ray: Ray3) -> Option<(Vec3, Vec3)> { + self.heightmap.raycast(ray) + } + + /// Applies a function to the heightmap + /// Returns the chunks that were modified + pub fn terrain_apply( + &mut self, + bounds: AABB, + f: impl FnMut(Vec3) -> f32, + ) -> Vec { + self.heightmap + .apply(bounds, f) + .into_iter() + .map(|(x, y)| TerrainChunkID::new_i16(x as i16, y as i16)) + .collect() + } + + pub fn terraform( + &mut self, + kind: TerraformKind, + center: Vec2, + radius: f32, + amount: f32, + ) -> Vec { + match kind { + TerraformKind::Raise => { + self.terrain_apply(AABB::centered(center, Vec2::splat(radius * 2.0)), |pos| { + let dist = pos.xy().distance(center) / radius; + if dist >= 1.0 { + return pos.z; + } + let phi = (-1.0 / (1.0 - dist * dist)).exp(); + pos.z + amount * phi + }) + } + } + } + fn generate_chunk(&self, (x, y): (u16, u16)) -> Option<(Chunk, Vec)> { let mut heights = [[0.0; TERRAIN_CHUNK_RESOLUTION]; TERRAIN_CHUNK_RESOLUTION];