diff --git a/CHANGELOG.md b/CHANGELOG.md index c03f670b7c..df523b2f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - NPCs now have unique names - A /scale command that can be used to change the in-game scale of players - Merchants will flog their wares in towns, encouraging nearby character to buy goods from them +- NPCs will now tell you about nearby towns and how to visit them +- NPCs will migrate to new towns if they are dissatisfied with their current town ### Changed @@ -50,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rescaling of images for the UI is now done when sampling from them on the GPU. Improvements are particularily noticeable when opening the map screen (which involves rescaling a few large images) and also when using the voxel minimap view (where a medium size image is updated often). +- Towns now have a variety of sizes ### Removed diff --git a/assets/common/loadout/village/captain.ron b/assets/common/loadout/village/captain.ron index c6617a5879..aef4a702b9 100644 --- a/assets/common/loadout/village/captain.ron +++ b/assets/common/loadout/village/captain.ron @@ -23,4 +23,6 @@ (1, Item("common.items.lantern.geode_purp")), (1, Item("common.items.boss_drops.lantern")), ]), + // TODO: Figure out what to do when airship captains die instead of this + tabard: Item("common.items.debug.admin"), ) diff --git a/assets/voxygen/i18n/en/npc.ftl b/assets/voxygen/i18n/en/npc.ftl index 5e53621f74..9f6025afa4 100644 --- a/assets/voxygen/i18n/en/npc.ftl +++ b/assets/voxygen/i18n/en/npc.ftl @@ -225,6 +225,9 @@ npc-speech-prisoner = .a4 = I wish i still had my pick! npc-speech-moving_on = .a0 = I've spent enough time here, onward to { $site }! +npc-speech-migrating = + .a0 = I'm no longer happy living here. Time to migrate to { $site }. + .a1 = Time to move to { $site }, I've had it with this place. npc-speech-night_time = .a0 = It's dark, time to head home. .a1 = I'm tired. @@ -247,6 +250,10 @@ npc-speech-merchant_sell_directed = .a0 = You there! Are you in need of a new thingamabob? .a1 = Are you hungry? I'm sure I've got some cheese you can buy. .a2 = You look like you could do with some new armour! +npc-speech-tell_site = + .a0 = Have you visited { $site }? It's just { $dir } of here! + .a1 = You should visit { $site } some time. + .a2 = If you travel { $dir }, you can get to { $site }. npc-speech-witness_murder = .a0 = Murderer! .a1 = How could you do this? @@ -255,3 +262,11 @@ npc-speech-witness_death = .a0 = No! .a1 = This is terrible! .a2 = Oh my goodness! +npc-speech-dir_north = north +npc-speech-dir_north_east = north-east +npc-speech-dir_east = east +npc-speech-dir_south_east = south-east +npc-speech-dir_south = south +npc-speech-dir_south_west = south-west +npc-speech-dir_west = west +npc-speech-dir_north_west = north-west diff --git a/client/src/lib.rs b/client/src/lib.rs index e891570bc2..5ae8d350b8 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -218,6 +218,7 @@ pub struct Client { player_list: HashMap, character_list: CharacterList, sites: HashMap, + possible_starting_sites: Vec, pois: Vec, pub chat_mode: ChatMode, recipe_book: RecipeBook, @@ -654,6 +655,7 @@ impl Client { Grid::from_raw(map_size.map(|e| e as i32), lod_horizon), (world_map_layers, map_size, map_bounds), world_map.sites, + world_map.possible_starting_sites, world_map.pois, recipe_book, component_recipe_book, @@ -670,6 +672,7 @@ impl Client { lod_horizon, world_map, sites, + possible_starting_sites, pois, recipe_book, component_recipe_book, @@ -709,6 +712,7 @@ impl Client { }) }) .collect(), + possible_starting_sites, pois, recipe_book, component_recipe_book, @@ -1348,6 +1352,8 @@ impl Client { /// Unstable, likely to be removed in a future release pub fn sites(&self) -> &HashMap { &self.sites } + pub fn possible_starting_sites(&self) -> &[SiteId] { &self.possible_starting_sites } + /// Unstable, likely to be removed in a future release pub fn pois(&self) -> &Vec { &self.pois } diff --git a/common/net/src/msg/world_msg.rs b/common/net/src/msg/world_msg.rs index 0a2ce94e6b..c379ae0283 100644 --- a/common/net/src/msg/world_msg.rs +++ b/common/net/src/msg/world_msg.rs @@ -121,6 +121,7 @@ pub struct WorldMapMsg { /// (256 possible angles). pub horizons: [(Vec, Vec); 2], pub sites: Vec, + pub possible_starting_sites: Vec, pub pois: Vec, /// Default chunk (representing the ocean outside the map bounds). Sea /// level (used to provide a base altitude) is the lower bound of this diff --git a/common/src/comp/chat.rs b/common/src/comp/chat.rs index ad5e55daee..4b5e4f73fc 100644 --- a/common/src/comp/chat.rs +++ b/common/src/comp/chat.rs @@ -231,6 +231,10 @@ pub enum LocalizationArg { Nat(u64), } +impl From for LocalizationArg { + fn from(content: Content) -> Self { Self::Content(content) } +} + // TODO: Remove impl and make use of `Content(Plain(...))` explicit (to // discourage it) impl From for LocalizationArg { diff --git a/common/src/comp/compass.rs b/common/src/comp/compass.rs index 4937e87d1c..f5e2ad1a1b 100644 --- a/common/src/comp/compass.rs +++ b/common/src/comp/compass.rs @@ -1,3 +1,4 @@ +use super::Content; use vek::Vec2; // TODO: Move this to common/src/, it's not a component @@ -39,7 +40,7 @@ impl Direction { } } - // TODO: localization + // TODO: Remove this in favour of `Direction::localize_npc`? pub fn name(&self) -> &'static str { match self { Direction::North => "North", @@ -52,6 +53,19 @@ impl Direction { Direction::Northwest => "Northwest", } } + + pub fn localize_npc(&self) -> Content { + Content::localized(match self { + Direction::North => "npc-speech-dir_north", + Direction::Northeast => "npc-speech-dir_north_east", + Direction::East => "npc-speech-dir_east", + Direction::Southeast => "npc-speech-dir_south_east", + Direction::South => "npc-speech-dir_south", + Direction::Southwest => "npc-speech-dir_south_west", + Direction::West => "npc-speech-dir_west", + Direction::Northwest => "npc-speech-dir_north_west", + }) + } } /// Arbitrarily named Distances diff --git a/rtsim/src/data/mod.rs b/rtsim/src/data/mod.rs index eb4e0748a9..12b200ff3a 100644 --- a/rtsim/src/data/mod.rs +++ b/rtsim/src/data/mod.rs @@ -29,7 +29,7 @@ use std::{ /// Note that this number does *not* need incrementing on every change: most /// field removals/additions are fine. This number should only be incremented /// when we wish to perform a *hard purge* of rtsim data. -pub const CURRENT_VERSION: u32 = 0; +pub const CURRENT_VERSION: u32 = 1; #[derive(Clone, Serialize, Deserialize)] pub struct Data { diff --git a/rtsim/src/data/npc.rs b/rtsim/src/data/npc.rs index c04b83202b..8f2897f8cd 100644 --- a/rtsim/src/data/npc.rs +++ b/rtsim/src/data/npc.rs @@ -55,6 +55,7 @@ pub struct PathingMemory { pub struct Controller { pub actions: Vec, pub activity: Option, + pub new_home: Option, } impl Controller { @@ -79,6 +80,8 @@ impl Controller { pub fn attack(&mut self, target: impl Into) { self.actions.push(NpcAction::Attack(target.into())); } + + pub fn set_new_home(&mut self, new_home: SiteId) { self.new_home = Some(new_home); } } pub struct Brain { diff --git a/rtsim/src/data/site.rs b/rtsim/src/data/site.rs index c736e49623..2ae27aa3d1 100644 --- a/rtsim/src/data/site.rs +++ b/rtsim/src/data/site.rs @@ -40,6 +40,13 @@ pub struct Site { // Note: there's currently no guarantee that site populations are non-intersecting #[serde(skip_serializing, skip_deserializing)] pub population: HashSet, + + /// A list of the nearby sites where each elements is both further and + /// larger (currently based on number of plots) than the next. + /// Effectively, this is a list of nearby sites that might be deemed + /// 'important' to the current one + #[serde(skip_serializing, skip_deserializing)] + pub nearby_sites_by_size: Vec, } impl Site { diff --git a/rtsim/src/gen/site.rs b/rtsim/src/gen/site.rs index fd4c09eed5..f398a26aef 100644 --- a/rtsim/src/gen/site.rs +++ b/rtsim/src/gen/site.rs @@ -53,6 +53,7 @@ impl Site { }), population: Default::default(), known_reports: Default::default(), + nearby_sites_by_size: Vec::new(), } } } diff --git a/rtsim/src/rule/npc_ai.rs b/rtsim/src/rule/npc_ai.rs index 7c562319a0..47af93136c 100644 --- a/rtsim/src/rule/npc_ai.rs +++ b/rtsim/src/rule/npc_ai.rs @@ -11,9 +11,9 @@ use crate::{ }; use common::{ astar::{Astar, PathResult}, - comp::Content, + comp::{compass::Direction, Content}, path::Path, - rtsim::{ChunkResource, Profession, SiteId}, + rtsim::{Actor, ChunkResource, Profession, SiteId}, spiral::Spiral2d, store::Id, terrain::{CoordinateConversions, SiteKindMeta, TerrainChunkSize}, @@ -378,6 +378,8 @@ fn travel_to_site(tgt_site: SiteId, speed_factor: f32) -> impl Action { now(move |ctx| { let sites = &ctx.state.data().sites; + let site_wpos = sites.get(tgt_site).map(|site| site.wpos.as_()); + // If we're currently in a site, try to find a path to the target site via // tracks if let Some(current_site) = ctx.npc.current_site @@ -458,8 +460,11 @@ fn travel_to_site(tgt_site: SiteId, speed_factor: f32) -> impl Action { // If we can't find a way to get to the site at all, there's nothing more to be done finish().boxed() } + // Stop the NPC early if we're near the site to prevent huddling around the centre + .stop_if(move |ctx| site_wpos.map_or(false, |site_wpos| ctx.npc.wpos.xy().distance_squared(site_wpos) < 16f32.powi(2))) }) - .debug(move || format!("travel_to_site {:?}", tgt_site)) + .debug(move || format!("travel_to_site {:?}", tgt_site)) + .map(|_| ()) } // Seconds @@ -489,7 +494,21 @@ fn socialize() -> impl Action { .choose(&mut ctx.rng) { return Either::Left( - just(move |ctx| ctx.controller.say(other, ctx.npc.personality.get_generic_comment(&mut ctx.rng))) + just(move |ctx| ctx.controller.say(other, if ctx.rng.gen_bool(0.3) + && let Some(current_site) = ctx.npc.current_site + && let Some(current_site) = ctx.state.data().sites.get(current_site) + && let Some(mention_site) = current_site.nearby_sites_by_size.choose(&mut ctx.rng) + && let Some(mention_site) = ctx.state.data().sites.get(*mention_site) + && let Some(mention_site_name) = mention_site.world_site + .map(|ws| ctx.index.sites.get(ws).name().to_string()) + { + Content::localized_with_args("npc-speech-tell_site", [ + ("site", Content::Plain(mention_site_name)), + ("dir", Direction::from_dir(mention_site.wpos.as_() - ctx.npc.wpos.xy()).localize_npc()), + ]) + } else { + ctx.npc.personality.get_generic_comment(&mut ctx.rng) + })) // After greeting the actor, wait for a while .then(idle().repeat().stop_if(timeout(4.0))) .map(|_| ()) @@ -599,28 +618,50 @@ fn choose_plaza(ctx: &mut NpcCtx, site: SiteId) -> Option> { fn villager(visiting_site: SiteId) -> impl Action { choose(move |ctx| { - /* - if ctx - .state - .data() - .sites - .get(visiting_site) - .map_or(true, |s| s.world_site.is_none()) + // Consider moving home if the home site gets too full + if ctx.rng.gen_bool(0.0001) + && let Some(home) = ctx.npc.home + && Some(home) == ctx.npc.current_site + && let Some(home_pop_ratio) = ctx.state.data().sites.get(home) + .and_then(|site| Some((site, ctx.index.sites.get(site.world_site?).site2()?))) + .map(|(site, site2)| site.population.len() as f32 / site2.plots().len() as f32) + // Only consider moving if the population is more than 1.5x the number of homes + .filter(|pop_ratio| *pop_ratio > 1.5) + && let Some(new_home) = ctx + .state + .data() + .sites + .iter() + // Don't try to move to the site that's currently our home + .filter(|(site_id, _)| Some(*site_id) != ctx.npc.home) + // Only consider towns as potential homes + .filter_map(|(site_id, site)| { + let site2 = match site.world_site.map(|ws| &ctx.index.sites.get(ws).kind) { + Some(SiteKind::Refactor(site2) + | SiteKind::CliffTown(site2) + | SiteKind::SavannahPit(site2) + | SiteKind::DesertCity(site2)) => site2, + _ => return None, + }; + Some((site_id, site, site2)) + }) + // Only select sites that are less densely populated than our own + .filter(|(_, site, site2)| (site.population.len() as f32 / site2.plots().len() as f32) < home_pop_ratio) + // Find the closest of the candidate sites + .min_by_key(|(_, site, _)| site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32) + .map(|(site_id, _, _)| site_id) { - return casual(idle() - .debug(|| "idling (visiting site does not exist, perhaps it's stale data?)")); - } else if ctx.npc.current_site != Some(visiting_site) { - let npc_home = ctx.npc.home; - // Travel to the site we're supposed to be in - return urgent(travel_to_site(visiting_site, 1.0).debug(move || { - if npc_home == Some(visiting_site) { - "travel home".to_string() - } else { - "travel to visiting site".to_string() + let site_name = ctx.state.data().sites[new_home].world_site + .map(|ws| ctx.index.sites.get(ws).name().to_string()); + return important(just(move |ctx| { + if let Some(site_name) = &site_name { + ctx.controller.say(None, Content::localized_with_args("npc-speech-migrating", [("site", site_name.clone())])) } - })); - } else - */ + }) + .then(travel_to_site(new_home, 0.5)) + .then(just(move |ctx| ctx.controller.set_new_home(new_home)))); + } + if DayPeriod::from(ctx.time_of_day.0).is_dark() && !matches!(ctx.npc.profession, Some(Profession::Guard)) { @@ -691,10 +732,10 @@ fn villager(visiting_site: SiteId) -> impl Action { .map(|_| ()), ); } - } else if matches!(ctx.npc.profession, Some(Profession::Guard)) && ctx.rng.gen_bool(0.5) { + } else if matches!(ctx.npc.profession, Some(Profession::Guard)) && ctx.rng.gen_bool(0.7) { if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) { - return important( - travel_to_point(plaza_wpos, 0.45) + return casual( + travel_to_point(plaza_wpos, 0.4) .debug(|| "patrol") .interrupt_with(|ctx| { if ctx.rng.gen_bool(0.0003) { @@ -896,8 +937,13 @@ fn check_inbox(ctx: &mut NpcCtx) -> Option { // TODO: Sentiment should be positive if we didn't like actor that died // TODO: Don't report self let phrase = if let Some(killer) = killer { - // TODO: Don't hard-code sentiment change - ctx.sentiments.change_by(killer, -0.7, Sentiment::VILLAIN); + // TODO: For now, we don't make sentiment changes if the killer was an + // NPC because NPCs can't hurt one-another. + // This should be changed in the future. + if !matches!(killer, Actor::Npc(_)) { + // TODO: Don't hard-code sentiment change + ctx.sentiments.change_by(killer, -0.7, Sentiment::VILLAIN); + } "npc-speech-witness_murder" } else { "npc-speech-witness_death" diff --git a/rtsim/src/rule/simulate_npcs.rs b/rtsim/src/rule/simulate_npcs.rs index c788d9527d..f319b67985 100644 --- a/rtsim/src/rule/simulate_npcs.rs +++ b/rtsim/src/rule/simulate_npcs.rs @@ -45,231 +45,248 @@ fn on_setup(ctx: EventCtx) { fn on_death(ctx: EventCtx) { let data = &mut *ctx.state.data_mut(); - if let Actor::Npc(npc_id) = ctx.event.actor - && let Some(npc) = data.npcs.get(npc_id) - { - let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()); + if let Actor::Npc(npc_id) = ctx.event.actor { + if let Some(npc) = data.npcs.get(npc_id) { + let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()); - // Respawn dead NPCs - let details = match npc.body { - Body::Humanoid(_) => { - if let Some((site_id, site)) = data - .sites - .iter() - .filter(|(id, site)| { - Some(*id) != npc.home - && (npc.faction.is_none() || site.faction == npc.faction) - && site.world_site.map_or(false, |s| { - matches!(ctx.index.sites.get(s).kind, SiteKind::Refactor(_) - | SiteKind::CliffTown(_) - | SiteKind::SavannahPit(_) - | SiteKind::DesertCity(_)) - }) - }) - .min_by_key(|(_, site)| site.population.len()) - { - let rand_wpos = |rng: &mut ChaChaRng| { - let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); - wpos2d - .map(|e| e as f32 + 0.5) - .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) - }; - let random_humanoid = |rng: &mut ChaChaRng| { - let species = comp::humanoid::ALL_SPECIES.choose(&mut *rng).unwrap(); - Body::Humanoid(comp::humanoid::Body::random_with(rng, species)) - }; - let npc_id = data.spawn_npc( - Npc::new(rng.gen(), rand_wpos(&mut rng), random_humanoid(&mut rng)) - .with_personality(Personality::random(&mut rng)) - .with_home(site_id) - .with_faction(npc.faction) - .with_profession(npc.profession.clone()), - ); - Some((npc_id, site_id)) - } else { - warn!("No site found for respawning humanoid"); + // Respawn dead NPCs + let details = match npc.body { + Body::Humanoid(_) => { + if let Some((site_id, site)) = data + .sites + .iter() + .filter(|(id, site)| { + Some(*id) != npc.home + && (npc.faction.is_none() || site.faction == npc.faction) + && site.world_site.map_or(false, |s| { + matches!( + ctx.index.sites.get(s).kind, + SiteKind::Refactor(_) + | SiteKind::CliffTown(_) + | SiteKind::SavannahPit(_) + | SiteKind::DesertCity(_) + ) + }) + }) + .min_by_key(|(_, site)| site.population.len()) + { + let rand_wpos = |rng: &mut ChaChaRng| { + let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); + wpos2d + .map(|e| e as f32 + 0.5) + .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) + }; + let random_humanoid = |rng: &mut ChaChaRng| { + let species = comp::humanoid::ALL_SPECIES.choose(&mut *rng).unwrap(); + Body::Humanoid(comp::humanoid::Body::random_with(rng, species)) + }; + let npc_id = data.spawn_npc( + Npc::new(rng.gen(), rand_wpos(&mut rng), random_humanoid(&mut rng)) + .with_personality(Personality::random(&mut rng)) + .with_home(site_id) + .with_faction(npc.faction) + .with_profession(npc.profession.clone()), + ); + Some((npc_id, site_id)) + } else { + warn!("No site found for respawning humanoid"); + None + } + }, + Body::BirdLarge(_) => { + if let Some((site_id, site)) = data + .sites + .iter() + .filter(|(id, site)| { + Some(*id) != npc.home + && site.world_site.map_or(false, |s| { + matches!(ctx.index.sites.get(s).kind, SiteKind::Dungeon(_)) + }) + }) + .min_by_key(|(_, site)| site.population.len()) + { + let rand_wpos = |rng: &mut ChaChaRng| { + let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); + wpos2d + .map(|e| e as f32 + 0.5) + .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) + }; + let species = [ + comp::body::bird_large::Species::Phoenix, + comp::body::bird_large::Species::Cockatrice, + comp::body::bird_large::Species::Roc, + ] + .choose(&mut rng) + .unwrap(); + let npc_id = data.npcs.create_npc( + Npc::new( + rng.gen(), + rand_wpos(&mut rng), + Body::BirdLarge(comp::body::bird_large::Body::random_with( + &mut rng, species, + )), + ) + .with_home(site_id), + ); + Some((npc_id, site_id)) + } else { + warn!("No site found for respawning bird"); + None + } + }, + body => { + error!("Tried to respawn rtsim NPC with invalid body: {:?}", body); None - } - }, - Body::BirdLarge(_) => { - if let Some((site_id, site)) = data - .sites - .iter() - .filter(|(id, site)| { - Some(*id) != npc.home - && site.world_site.map_or(false, |s| { - matches!(ctx.index.sites.get(s).kind, SiteKind::Dungeon(_)) - }) - }) - .min_by_key(|(_, site)| site.population.len()) - { - let rand_wpos = |rng: &mut ChaChaRng| { - let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10)); - wpos2d - .map(|e| e as f32 + 0.5) - .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0)) - }; - let species = [ - comp::body::bird_large::Species::Phoenix, - comp::body::bird_large::Species::Cockatrice, - comp::body::bird_large::Species::Roc, - ] - .choose(&mut rng) - .unwrap(); - let npc_id = data.npcs.create_npc( - Npc::new( - rng.gen(), - rand_wpos(&mut rng), - Body::BirdLarge(comp::body::bird_large::Body::random_with( - &mut rng, species, - )), - ) - .with_home(site_id), - ); - Some((npc_id, site_id)) - } else { - warn!("No site found for respawning bird"); - None - } - }, - body => { - error!("Tried to respawn rtsim NPC with invalid body: {:?}", body); - None - }, - }; + }, + }; - // Add the NPC to their home site - if let Some((npc_id, home_site)) = details { - if let Some(home) = data.sites.get_mut(home_site) { - home.population.insert(npc_id); + // Add the NPC to their home site + if let Some((npc_id, home_site)) = details { + if let Some(home) = data.sites.get_mut(home_site) { + home.population.insert(npc_id); + } } + } else { + error!("Trying to respawn non-existent NPC"); } - } else { - error!("Trying to respawn non-existent NPC"); } } fn on_tick(ctx: EventCtx) { let data = &mut *ctx.state.data_mut(); - for npc in data - .npcs - .npcs - .values_mut() - .filter(|npc| matches!(npc.mode, SimulationMode::Simulated) && !npc.is_dead) - { - // Simulate NPC movement when riding - if let Some(riding) = &npc.riding { - if let Some(vehicle) = data.npcs.vehicles.get_mut(riding.vehicle) { + for (npc_id, npc) in data.npcs.npcs.iter_mut().filter(|(_, npc)| !npc.is_dead) { + if matches!(npc.mode, SimulationMode::Simulated) { + // Simulate NPC movement when riding + if let Some(riding) = &npc.riding { + if let Some(vehicle) = data.npcs.vehicles.get_mut(riding.vehicle) { + match npc.controller.activity { + // If steering, the NPC controls the vehicle's motion + Some(NpcActivity::Goto(target, speed_factor)) if riding.steering => { + let diff = target.xy() - vehicle.wpos.xy(); + let dist2 = diff.magnitude_squared(); + + if dist2 > 0.5f32.powi(2) { + let mut wpos = vehicle.wpos + + (diff + * (vehicle.get_speed() * speed_factor * ctx.event.dt + / dist2.sqrt()) + .min(1.0)) + .with_z(0.0); + + let is_valid = match vehicle.body { + common::comp::ship::Body::DefaultAirship + | common::comp::ship::Body::AirBalloon => true, + common::comp::ship::Body::SailBoat + | common::comp::ship::Body::Galleon => { + let chunk_pos = wpos.xy().as_().wpos_to_cpos(); + ctx.world + .sim() + .get(chunk_pos) + .map_or(true, |f| f.river.river_kind.is_some()) + }, + _ => false, + }; + + if is_valid { + match vehicle.body { + common::comp::ship::Body::DefaultAirship + | common::comp::ship::Body::AirBalloon => { + if let Some(alt) = ctx + .world + .sim() + .get_alt_approx(wpos.xy().as_()) + .filter(|alt| wpos.z < *alt) + { + wpos.z = alt; + } + }, + common::comp::ship::Body::SailBoat + | common::comp::ship::Body::Galleon => { + wpos.z = ctx + .world + .sim() + .get_interpolated( + wpos.xy().map(|e| e as i32), + |chunk| chunk.water_alt, + ) + .unwrap_or(0.0); + }, + _ => {}, + } + vehicle.wpos = wpos; + } + } + }, + // When riding, other actions are disabled + Some( + NpcActivity::Goto(_, _) + | NpcActivity::Gather(_) + | NpcActivity::HuntAnimals + | NpcActivity::Dance, + ) => {}, + None => {}, + } + npc.wpos = vehicle.wpos; + } else { + // Vehicle doens't exist anymore + npc.riding = None; + } + // If not riding, we assume they're just walking + } else { match npc.controller.activity { - // If steering, the NPC controls the vehicle's motion - Some(NpcActivity::Goto(target, speed_factor)) if riding.steering => { - let diff = target.xy() - vehicle.wpos.xy(); + // Move NPCs if they have a target destination + Some(NpcActivity::Goto(target, speed_factor)) => { + let diff = target.xy() - npc.wpos.xy(); let dist2 = diff.magnitude_squared(); if dist2 > 0.5f32.powi(2) { - let mut wpos = vehicle.wpos - + (diff - * (vehicle.get_speed() * speed_factor * ctx.event.dt - / dist2.sqrt()) - .min(1.0)) - .with_z(0.0); - - let is_valid = match vehicle.body { - common::comp::ship::Body::DefaultAirship - | common::comp::ship::Body::AirBalloon => true, - common::comp::ship::Body::SailBoat - | common::comp::ship::Body::Galleon => { - let chunk_pos = wpos.xy().as_().wpos_to_cpos(); - ctx.world - .sim() - .get(chunk_pos) - .map_or(true, |f| f.river.river_kind.is_some()) - }, - _ => false, - }; - - if is_valid { - match vehicle.body { - common::comp::ship::Body::DefaultAirship - | common::comp::ship::Body::AirBalloon => { - if let Some(alt) = ctx - .world - .sim() - .get_alt_approx(wpos.xy().as_()) - .filter(|alt| wpos.z < *alt) - { - wpos.z = alt; - } - }, - common::comp::ship::Body::SailBoat - | common::comp::ship::Body::Galleon => { - wpos.z = ctx - .world - .sim() - .get_interpolated( - wpos.xy().map(|e| e as i32), - |chunk| chunk.water_alt, - ) - .unwrap_or(0.0); - }, - _ => {}, - } - vehicle.wpos = wpos; - } + npc.wpos += (diff + * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt + / dist2.sqrt()) + .min(1.0)) + .with_z(0.0); } }, - // When riding, other actions are disabled Some( - NpcActivity::Goto(_, _) - | NpcActivity::Gather(_) - | NpcActivity::HuntAnimals - | NpcActivity::Dance, - ) => {}, + NpcActivity::Gather(_) | NpcActivity::HuntAnimals | NpcActivity::Dance, + ) => { + // TODO: Maybe they should walk around randomly + // when gathering resources? + }, None => {}, } - npc.wpos = vehicle.wpos; - } else { - // Vehicle doens't exist anymore - npc.riding = None; } - // If not riding, we assume they're just walking - } else { - match npc.controller.activity { - // Move NPCs if they have a target destination - Some(NpcActivity::Goto(target, speed_factor)) => { - let diff = target.xy() - npc.wpos.xy(); - let dist2 = diff.magnitude_squared(); - if dist2 > 0.5f32.powi(2) { - npc.wpos += (diff - * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt - / dist2.sqrt()) - .min(1.0)) - .with_z(0.0); - } - }, - Some(NpcActivity::Gather(_) | NpcActivity::HuntAnimals | NpcActivity::Dance) => { - // TODO: Maybe they should walk around randomly - // when gathering resources? - }, - None => {}, + // Consume NPC actions + for action in std::mem::take(&mut npc.controller.actions) { + match action { + NpcAction::Say(_, _) => {}, // Currently, just swallow interactions + NpcAction::Attack(_) => {}, // TODO: Implement simulated combat + } } + + // Make sure NPCs remain on the surface + npc.wpos.z = ctx + .world + .sim() + .get_surface_alt_approx(npc.wpos.xy().map(|e| e as i32)) + .unwrap_or(0.0) + + npc.body.flying_height(); } - // Consume NPC actions - for action in std::mem::take(&mut npc.controller.actions) { - match action { - NpcAction::Say(_, _) => {}, // Currently, just swallow interactions - NpcAction::Attack(_) => {}, // TODO: Implement simulated combat + // Move home if required + if let Some(new_home) = npc.controller.new_home.take() { + // Remove the NPC from their old home population + if let Some(old_home) = npc.home { + if let Some(old_home) = data.sites.get_mut(old_home) { + old_home.population.remove(&npc_id); + } } + // Add the NPC to their new home population + if let Some(new_home) = data.sites.get_mut(new_home) { + new_home.population.insert(npc_id); + } + npc.home = Some(new_home); } - - // Make sure NPCs remain on the surface - npc.wpos.z = ctx - .world - .sim() - .get_surface_alt_approx(npc.wpos.xy().map(|e| e as i32)) - .unwrap_or(0.0) - + npc.body.flying_height(); } } diff --git a/rtsim/src/rule/sync_npcs.rs b/rtsim/src/rule/sync_npcs.rs index af71b08779..d89ffc3416 100644 --- a/rtsim/src/rule/sync_npcs.rs +++ b/rtsim/src/rule/sync_npcs.rs @@ -28,6 +28,43 @@ fn on_setup(ctx: EventCtx) { home.population.insert(npc_id); } } + + // Update the list of nearest sites by size for each site + let sites_iter = data.sites.iter().filter_map(|(site_id, site)| { + let site2 = site + .world_site + .and_then(|ws| ctx.index.sites.get(ws).site2())?; + Some((site_id, site, site2)) + }); + let nearest_by_size = sites_iter.clone() + .map(|(site_id, site, site2)| { + let mut other_sites = sites_iter.clone() + // Only include sites in the list if they're not the current one and they're more populus + .filter(|(other_id, _, other_site2)| *other_id != site_id && other_site2.plots().len() > site2.plots().len()) + .collect::>(); + other_sites.sort_by_key(|(_, other, _)| other.wpos.distance_squared(site.wpos) as i64); + let mut max_size = 0; + // Remove sites that aren't in increasing order of size (Stalin sort?!) + other_sites.retain(|(_, _, other_site2)| { + if other_site2.plots().len() > max_size { + max_size = other_site2.plots().len(); + true + } else { + false + } + }); + let nearest_by_size = other_sites + .into_iter() + .map(|(site_id, _, _)| site_id) + .collect::>(); + (site_id, nearest_by_size) + }) + .collect::>(); + for (site_id, nearest_by_size) in nearest_by_size { + if let Some(site) = data.sites.get_mut(site_id) { + site.nearby_sites_by_size = nearest_by_size; + } + } } fn on_death(ctx: EventCtx) { diff --git a/server/src/events/entity_manipulation.rs b/server/src/events/entity_manipulation.rs index cde5f31602..9e39fa1198 100644 --- a/server/src/events/entity_manipulation.rs +++ b/server/src/events/entity_manipulation.rs @@ -513,12 +513,11 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt inventory.damage_items(&ability_map, &msm); } - if should_delete { - if let Some(actor) = state.entity_as_actor(entity) { - state - .ecs() - .write_resource::() - .hook_rtsim_actor_death( + if let Some(actor) = state.entity_as_actor(entity) { + state + .ecs() + .write_resource::() + .hook_rtsim_actor_death( &state.ecs().read_resource::>(), state .ecs() @@ -540,8 +539,9 @@ pub fn handle_destroy(server: &mut Server, entity: EcsEntity, last_change: Healt ) .and_then(|killer| state.entity_as_actor(killer)), ); - } + } + if should_delete { if let Err(e) = state.delete_entity_recorded(entity) { error!(?e, ?entity, "Failed to delete destroyed entity"); } diff --git a/voxygen/src/menu/char_selection/ui/mod.rs b/voxygen/src/menu/char_selection/ui/mod.rs index e644523d42..42024dfb1c 100644 --- a/voxygen/src/menu/char_selection/ui/mod.rs +++ b/voxygen/src/menu/char_selection/ui/mod.rs @@ -29,7 +29,7 @@ use common::{ vol::RectVolSize, LoadoutBuilder, }; -use common_net::msg::world_msg::{SiteId, SiteInfo, SiteKind}; +use common_net::msg::world_msg::{SiteId, SiteInfo}; use i18n::{Localization, LocalizationHandle}; //ImageFrame, Tooltip, use crate::settings::Settings; @@ -1978,10 +1978,10 @@ impl CharSelectionUi { Arc::clone(client.world_data().topo_map_image()), Some(default_water_color()), )), - client.sites() - .values() - // TODO: Enforce this server-side and add some way to customise it? - .filter(|info| matches!(&info.site.kind, SiteKind::Town /*| SiteKind::Castle | SiteKind::Bridge*/)) + client + .possible_starting_sites() + .iter() + .filter_map(|site_id| client.sites().get(site_id)) .map(|info| info.site.clone()) .collect(), client.world_data().chunk_size().as_(), diff --git a/world/src/civ/mod.rs b/world/src/civ/mod.rs index e482d844c6..4896fb520e 100644 --- a/world/src/civ/mod.rs +++ b/world/src/civ/mod.rs @@ -359,11 +359,15 @@ impl Civs { SiteKind::Castle => { WorldSite::castle(Castle::generate(wpos, Some(ctx.sim), &mut rng)) }, - SiteKind::Refactor => WorldSite::refactor(site2::Site::generate_city( - &Land::from_sim(ctx.sim), - &mut rng, - wpos, - )), + SiteKind::Refactor => { + let size = Lerp::lerp(0.03, 1.0, rng.gen_range(0.0..1f32).powi(5)); + WorldSite::refactor(site2::Site::generate_city( + &Land::from_sim(ctx.sim), + &mut rng, + wpos, + size, + )) + }, SiteKind::CliffTown => WorldSite::cliff_town(site2::Site::generate_cliff_town( &Land::from_sim(ctx.sim), &mut rng, diff --git a/world/src/lib.rs b/world/src/lib.rs index 0ac0aef614..fe59e1b8c0 100644 --- a/world/src/lib.rs +++ b/world/src/lib.rs @@ -6,7 +6,7 @@ )] #![allow(clippy::branches_sharing_code)] // TODO: evaluate #![deny(clippy::clone_on_ref_ptr)] -#![feature(option_zip)] +#![feature(option_zip, let_chains)] mod all; mod block; @@ -205,6 +205,37 @@ impl World { wpos, })) .collect(), + possible_starting_sites: { + const STARTING_SITE_COUNT: usize = 4; + + let mut candidates = self + .civs() + .sites + .iter() + .filter_map(|(_, civ_site)| Some((civ_site, civ_site.site_tmp?))) + .map(|(civ_site, site_id)| { + // Score the site according to how suitable it is to be a starting site + let mut score = 0.0; + + if let SiteKind::Refactor(site2) = &index.sites[site_id].kind { + // Strongly prefer towns + score += 1000.0; + // Prefer sites of a medium size + score += 2.0 / (1.0 + (site2.plots().len() as f32 - 20.0).abs() / 10.0); + }; + // Prefer sites in hospitable climates + if let Some(chunk) = self.sim().get(civ_site.center) { + score += 1.0 / (1.0 + chunk.temp.abs()); + score += 1.0 / (1.0 + (chunk.humidity - CONFIG.forest_hum).abs() * 2.0); + } + // Prefer sites that are close to the centre of the world + score += 4.0 / (1.0 + civ_site.center.map2(self.sim().get_size(), |e, sz| (e as f32 / sz as f32 - 0.5).abs() * 2.0).reduce_partial_max()); + (site_id.id(), score) + }) + .collect::>(); + candidates.sort_by_key(|(_, score)| -(*score * 1000.0) as i32); + candidates.into_iter().map(|(site_id, _)| site_id).take(STARTING_SITE_COUNT).collect() + }, ..self.sim.get_map(index, self.sim().calendar.as_ref()) } }) diff --git a/world/src/sim/mod.rs b/world/src/sim/mod.rs index cc8173d168..a629a417af 100644 --- a/world/src/sim/mod.rs +++ b/world/src/sim/mod.rs @@ -1681,8 +1681,9 @@ impl WorldSim { rgba: Grid::from_raw(self.get_size().map(|e| e as i32), v), alt: Grid::from_raw(self.get_size().map(|e| e as i32), alts), horizons, - sites: Vec::new(), // Will be substituted later - pois: Vec::new(), // Will be substituted later + sites: Vec::new(), // Will be substituted later + pois: Vec::new(), // Will be substituted later + possible_starting_sites: Vec::new(), // Will be substituted later default_chunk: Arc::new(self.generate_oob_chunk()), } } diff --git a/world/src/site2/mod.rs b/world/src/site2/mod.rs index 8d8964ab5f..d7a9cd1f00 100644 --- a/world/src/site2/mod.rs +++ b/world/src/site2/mod.rs @@ -493,7 +493,8 @@ impl Site { site } - pub fn generate_city(land: &Land, rng: &mut impl Rng, origin: Vec2) -> Self { + // Size is 0..1 + pub fn generate_city(land: &Land, rng: &mut impl Rng, origin: Vec2, size: f32) -> Self { let mut rng = reseed(rng); let mut site = Site { @@ -510,7 +511,7 @@ impl Site { let mut castles = 0; - for _ in 0..120 { + for _ in 0..(size * 200.0) as i32 { match *build_chance.choose_seeded(rng.gen()) { // House 1 => { @@ -1417,7 +1418,9 @@ impl Site { } } -pub fn test_site() -> Site { Site::generate_city(&Land::empty(), &mut thread_rng(), Vec2::zero()) } +pub fn test_site() -> Site { + Site::generate_city(&Land::empty(), &mut thread_rng(), Vec2::zero(), 0.5) +} fn wpos_is_hazard(land: &Land, wpos: Vec2) -> Option { if land