mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
1035 lines
39 KiB
Rust
1035 lines
39 KiB
Rust
use std::hash::BuildHasherDefault;
|
|
|
|
use crate::{
|
|
ai::{casual, choose, finish, important, just, now, seq, until, Action, NpcCtx},
|
|
data::{
|
|
npc::{Brain, PathData, SimulationMode},
|
|
ReportKind, Sentiment, Sites,
|
|
},
|
|
event::OnTick,
|
|
RtState, Rule, RuleError,
|
|
};
|
|
use common::{
|
|
astar::{Astar, PathResult},
|
|
comp::{compass::Direction, Content},
|
|
path::Path,
|
|
rtsim::{Actor, ChunkResource, Profession, SiteId},
|
|
spiral::Spiral2d,
|
|
store::Id,
|
|
terrain::{CoordinateConversions, SiteKindMeta, TerrainChunkSize},
|
|
time::DayPeriod,
|
|
};
|
|
use fxhash::FxHasher64;
|
|
use itertools::{Either, Itertools};
|
|
use rand::prelude::*;
|
|
use rand_chacha::ChaChaRng;
|
|
use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
|
|
use vek::*;
|
|
use world::{
|
|
civ::{self, Track},
|
|
site::{Site as WorldSite, SiteKind},
|
|
site2::{self, PlotKind, TileKind},
|
|
util::NEIGHBORS,
|
|
IndexRef, World,
|
|
};
|
|
|
|
/// How many ticks should pass between running NPC AI.
|
|
/// Note that this only applies to simulated NPCs: loaded NPCs have their AI
|
|
/// code run every tick. This means that AI code should be broadly
|
|
/// DT-independent.
|
|
const SIMULATED_TICK_SKIP: u64 = 10;
|
|
|
|
pub struct NpcAi;
|
|
|
|
const CARDINALS: &[Vec2<i32>] = &[
|
|
Vec2::new(1, 0),
|
|
Vec2::new(0, 1),
|
|
Vec2::new(-1, 0),
|
|
Vec2::new(0, -1),
|
|
];
|
|
|
|
fn path_in_site(start: Vec2<i32>, end: Vec2<i32>, site: &site2::Site) -> PathResult<Vec2<i32>> {
|
|
let heuristic = |tile: &Vec2<i32>, _: &Vec2<i32>| tile.as_::<f32>().distance(end.as_());
|
|
let mut astar = Astar::new(1000, start, BuildHasherDefault::<FxHasher64>::default());
|
|
|
|
let transition = |a: &Vec2<i32>, b: &Vec2<i32>| {
|
|
let distance = a.as_::<f32>().distance(b.as_());
|
|
let a_tile = site.tiles.get(*a);
|
|
let b_tile = site.tiles.get(*b);
|
|
|
|
let terrain = match &b_tile.kind {
|
|
TileKind::Empty => 3.0,
|
|
TileKind::Hazard(_) => 50.0,
|
|
TileKind::Field => 8.0,
|
|
TileKind::Plaza | TileKind::Road { .. } | TileKind::Path | TileKind::Bridge => 1.0,
|
|
|
|
TileKind::Building
|
|
| TileKind::Castle
|
|
| TileKind::Wall(_)
|
|
| TileKind::Tower(_)
|
|
| TileKind::Keep(_)
|
|
| TileKind::Gate
|
|
| TileKind::GnarlingFortification => 5.0,
|
|
};
|
|
let is_door_tile = |plot: Id<site2::Plot>, tile: Vec2<i32>| match site.plot(plot).kind() {
|
|
site2::PlotKind::House(house) => house.door_tile == tile,
|
|
site2::PlotKind::Workshop(_) => true,
|
|
_ => false,
|
|
};
|
|
let building = if a_tile.is_building() && b_tile.is_road() {
|
|
a_tile
|
|
.plot
|
|
.and_then(|plot| is_door_tile(plot, *a).then_some(1.0))
|
|
.unwrap_or(10000.0)
|
|
} else if b_tile.is_building() && a_tile.is_road() {
|
|
b_tile
|
|
.plot
|
|
.and_then(|plot| is_door_tile(plot, *b).then_some(1.0))
|
|
.unwrap_or(10000.0)
|
|
} else if (a_tile.is_building() || b_tile.is_building()) && a_tile.plot != b_tile.plot {
|
|
10000.0
|
|
} else {
|
|
1.0
|
|
};
|
|
|
|
distance * terrain + building
|
|
};
|
|
|
|
let neighbors = |tile: &Vec2<i32>| {
|
|
let tile = *tile;
|
|
CARDINALS.iter().map(move |c| tile + *c)
|
|
};
|
|
|
|
astar.poll(1000, heuristic, neighbors, transition, |tile| {
|
|
*tile == end || site.tiles.get_known(*tile).is_none()
|
|
})
|
|
}
|
|
|
|
fn path_between_sites(
|
|
start: SiteId,
|
|
end: SiteId,
|
|
sites: &Sites,
|
|
world: &World,
|
|
) -> PathResult<(Id<Track>, bool)> {
|
|
let world_site = |site_id: SiteId| {
|
|
let id = sites.get(site_id).and_then(|site| site.world_site)?;
|
|
world.civs().sites.recreate_id(id.id())
|
|
};
|
|
|
|
let start = if let Some(start) = world_site(start) {
|
|
start
|
|
} else {
|
|
return PathResult::Pending;
|
|
};
|
|
let end = if let Some(end) = world_site(end) {
|
|
end
|
|
} else {
|
|
return PathResult::Pending;
|
|
};
|
|
|
|
let get_site = |site: &Id<civ::Site>| world.civs().sites.get(*site);
|
|
|
|
let end_pos = get_site(&end).center.as_::<f32>();
|
|
let heuristic =
|
|
|site: &Id<civ::Site>, _: &Id<civ::Site>| get_site(site).center.as_().distance(end_pos);
|
|
|
|
let mut astar = Astar::new(250, start, BuildHasherDefault::<FxHasher64>::default());
|
|
|
|
let neighbors = |site: &Id<civ::Site>| world.civs().neighbors(*site);
|
|
|
|
let transition = |a: &Id<civ::Site>, b: &Id<civ::Site>| {
|
|
world.civs().track_between(*a, *b).map(|(id, _)| {
|
|
world.civs().tracks.get(id).cost
|
|
}).unwrap_or(f32::INFINITY)
|
|
};
|
|
|
|
let path = astar.poll(250, heuristic, neighbors, transition, |site| *site == end);
|
|
|
|
path.map(|path| {
|
|
let path = path
|
|
.into_iter()
|
|
.tuple_windows::<(_, _)>()
|
|
// Since we get a, b from neighbors, track_between shouldn't return None.
|
|
.filter_map(|(a, b)| world.civs().track_between(a, b))
|
|
.collect();
|
|
Path { nodes: path }
|
|
})
|
|
}
|
|
|
|
fn path_site(
|
|
start: Vec2<f32>,
|
|
end: Vec2<f32>,
|
|
site: Id<WorldSite>,
|
|
index: IndexRef,
|
|
) -> Option<Vec<Vec2<f32>>> {
|
|
if let Some(site) = index.sites.get(site).site2() {
|
|
let start = site.wpos_tile_pos(start.as_());
|
|
|
|
let end = site.wpos_tile_pos(end.as_());
|
|
|
|
let nodes = match path_in_site(start, end, site) {
|
|
PathResult::Path(p) => p.nodes,
|
|
PathResult::Exhausted(p) => p.nodes,
|
|
PathResult::None(_) | PathResult::Pending => return None,
|
|
};
|
|
|
|
Some(
|
|
nodes
|
|
.into_iter()
|
|
.map(|tile| site.tile_center_wpos(tile).as_() + 0.5)
|
|
.collect(),
|
|
)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn path_towns(
|
|
start: SiteId,
|
|
end: SiteId,
|
|
sites: &Sites,
|
|
world: &World,
|
|
) -> Option<PathData<(Id<Track>, bool), SiteId>> {
|
|
match path_between_sites(start, end, sites, world) {
|
|
PathResult::Exhausted(p) => Some(PathData {
|
|
end,
|
|
path: p.nodes.into(),
|
|
repoll: true,
|
|
}),
|
|
PathResult::Path(p) => Some(PathData {
|
|
end,
|
|
path: p.nodes.into(),
|
|
repoll: false,
|
|
}),
|
|
PathResult::Pending | PathResult::None(_) => None,
|
|
}
|
|
}
|
|
|
|
impl Rule for NpcAi {
|
|
fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
|
|
rtstate.bind::<Self, OnTick>(|ctx| {
|
|
// Temporarily take the brains of NPCs out of their heads to appease the borrow
|
|
// checker
|
|
let mut npc_data = {
|
|
let mut data = ctx.state.data_mut();
|
|
data.npcs
|
|
.iter_mut()
|
|
// Don't run AI for dead NPCs
|
|
.filter(|(_, npc)| !npc.is_dead)
|
|
// Don't run AI for simulated NPCs every tick
|
|
.filter(|(_, npc)| matches!(npc.mode, SimulationMode::Loaded) || (npc.seed as u64 + ctx.event.tick) % SIMULATED_TICK_SKIP == 0)
|
|
.map(|(npc_id, npc)| {
|
|
let controller = std::mem::take(&mut npc.controller);
|
|
let inbox = std::mem::take(&mut npc.inbox);
|
|
let sentiments = std::mem::take(&mut npc.sentiments);
|
|
let known_reports = std::mem::take(&mut npc.known_reports);
|
|
let brain = npc.brain.take().unwrap_or_else(|| Brain {
|
|
action: Box::new(think().repeat()),
|
|
});
|
|
(npc_id, controller, inbox, sentiments, known_reports, brain)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
|
|
// Do a little thinking
|
|
{
|
|
let data = &*ctx.state.data();
|
|
|
|
npc_data
|
|
.par_iter_mut()
|
|
.for_each(|(npc_id, controller, inbox, sentiments, known_reports, brain)| {
|
|
let npc = &data.npcs[*npc_id];
|
|
|
|
brain.action.tick(&mut NpcCtx {
|
|
state: ctx.state,
|
|
world: ctx.world,
|
|
index: ctx.index,
|
|
time_of_day: ctx.event.time_of_day,
|
|
time: ctx.event.time,
|
|
npc,
|
|
npc_id: *npc_id,
|
|
controller,
|
|
inbox,
|
|
known_reports,
|
|
sentiments,
|
|
rng: ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>()),
|
|
});
|
|
});
|
|
}
|
|
|
|
// Reinsert NPC brains
|
|
let mut data = ctx.state.data_mut();
|
|
for (npc_id, controller, inbox, sentiments, known_reports, brain) in npc_data {
|
|
data.npcs[npc_id].controller = controller;
|
|
data.npcs[npc_id].brain = Some(brain);
|
|
data.npcs[npc_id].inbox = inbox;
|
|
data.npcs[npc_id].sentiments = sentiments;
|
|
data.npcs[npc_id].known_reports = known_reports;
|
|
}
|
|
});
|
|
|
|
Ok(Self)
|
|
}
|
|
}
|
|
|
|
fn idle() -> impl Action { just(|ctx| ctx.controller.do_idle()).debug(|| "idle") }
|
|
|
|
/// Try to walk toward a 3D position without caring for obstacles.
|
|
fn goto(wpos: Vec3<f32>, speed_factor: f32, goal_dist: f32, height_offset: f32) -> impl Action {
|
|
const STEP_DIST: f32 = 24.0;
|
|
const WAYPOINT_DIST: f32 = 12.0;
|
|
|
|
let mut waypoint = None;
|
|
|
|
just(move |ctx| {
|
|
let rpos = wpos - ctx.npc.wpos;
|
|
let len = rpos.magnitude();
|
|
|
|
// If we're close to the next waypoint, complete it
|
|
if waypoint.map_or(false, |waypoint: Vec3<f32>| {
|
|
ctx.npc.wpos.xy().distance_squared(waypoint.xy()) < WAYPOINT_DIST.powi(2)
|
|
}) {
|
|
waypoint = None;
|
|
}
|
|
|
|
// Get the next waypoint on the route toward the goal
|
|
let waypoint = waypoint.get_or_insert_with(|| {
|
|
let wpos = ctx.npc.wpos + (rpos / len) * len.min(STEP_DIST);
|
|
|
|
wpos.with_z(
|
|
ctx.world
|
|
.sim()
|
|
.get_surface_alt_approx(wpos.xy().as_())
|
|
.map(|alt| alt + height_offset)
|
|
.unwrap_or(wpos.z),
|
|
)
|
|
});
|
|
|
|
ctx.controller.do_goto(*waypoint, speed_factor);
|
|
})
|
|
.repeat()
|
|
.stop_if(move |ctx| ctx.npc.wpos.xy().distance_squared(wpos.xy()) < goal_dist.powi(2))
|
|
.debug(move || format!("goto {}, {}, {}", wpos.x, wpos.y, wpos.z))
|
|
.map(|_| {})
|
|
}
|
|
|
|
/// Try to walk toward a 2D position on the terrain without caring for
|
|
/// obstacles.
|
|
fn goto_2d(
|
|
wpos2d: Vec2<f32>,
|
|
speed_factor: f32,
|
|
goal_dist: f32,
|
|
height_offset: f32,
|
|
) -> impl Action {
|
|
now(move |ctx| {
|
|
let wpos = wpos2d
|
|
.with_z(ctx.world.sim().get_alt_approx(wpos2d.as_()).unwrap_or(0.0) + height_offset);
|
|
goto(wpos, speed_factor, goal_dist, height_offset)
|
|
})
|
|
}
|
|
|
|
fn traverse_points<F>(mut next_point: F, speed_factor: f32, height_offset: f32) -> impl Action
|
|
where
|
|
F: FnMut(&mut NpcCtx) -> Option<Vec2<f32>> + Send + Sync + 'static,
|
|
{
|
|
until(move |ctx| {
|
|
let wpos = next_point(ctx)?;
|
|
|
|
let wpos_site = |wpos: Vec2<f32>| {
|
|
ctx.world
|
|
.sim()
|
|
.get(wpos.as_().wpos_to_cpos())
|
|
.and_then(|chunk| chunk.sites.first().copied())
|
|
};
|
|
|
|
// If we're traversing within a site, to intra-site pathfinding
|
|
if let Some(site) = wpos_site(wpos) {
|
|
let mut site_exit = wpos;
|
|
while let Some(next) = next_point(ctx).filter(|next| wpos_site(*next) == Some(site)) {
|
|
site_exit = next;
|
|
}
|
|
|
|
if let Some(path) = path_site(wpos, site_exit, site, ctx.index) {
|
|
Some(Either::Left(
|
|
seq(path
|
|
.into_iter()
|
|
.map(move |wpos| goto_2d(wpos, 1.0, 8.0, height_offset)))
|
|
.then(goto_2d(site_exit, speed_factor, 8.0, height_offset)),
|
|
))
|
|
} else {
|
|
Some(Either::Right(goto_2d(
|
|
site_exit,
|
|
speed_factor,
|
|
8.0,
|
|
height_offset,
|
|
)))
|
|
}
|
|
} else {
|
|
Some(Either::Right(goto_2d(
|
|
wpos,
|
|
speed_factor,
|
|
8.0,
|
|
height_offset,
|
|
)))
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Try to travel to a site. Where practical, paths will be taken.
|
|
fn travel_to_point(wpos: Vec2<f32>, speed_factor: f32) -> impl Action {
|
|
now(move |ctx| {
|
|
const WAYPOINT: f32 = 48.0;
|
|
let start = ctx.npc.wpos.xy();
|
|
let diff = wpos - start;
|
|
let n = (diff.magnitude() / WAYPOINT).max(1.0);
|
|
let mut points = (1..n as usize + 1).map(move |i| start + diff * (i as f32 / n));
|
|
traverse_points(move |_| points.next(), speed_factor, 0.0)
|
|
})
|
|
.debug(|| "travel to point")
|
|
}
|
|
|
|
/// Try to travel to a site. Where practical, paths will be taken.
|
|
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
|
|
&& let Some(tracks) = path_towns(current_site, tgt_site, sites, ctx.world)
|
|
{
|
|
|
|
let mut nodes = tracks.path
|
|
.into_iter()
|
|
.flat_map(move |(track_id, reversed)| (0..)
|
|
.map(move |node_idx| (node_idx, track_id, reversed)));
|
|
|
|
traverse_points(move |ctx| {
|
|
let (node_idx, track_id, reversed) = nodes.next()?;
|
|
let nodes = &ctx.world.civs().tracks.get(track_id).path().nodes;
|
|
|
|
// Handle the case where we walk paths backward
|
|
let idx = if reversed {
|
|
nodes.len().checked_sub(node_idx + 1)
|
|
} else {
|
|
Some(node_idx)
|
|
};
|
|
|
|
if let Some(node) = idx.and_then(|idx| nodes.get(idx)) {
|
|
// Find the centre of the track node's chunk
|
|
let node_chunk_wpos = TerrainChunkSize::center_wpos(*node);
|
|
|
|
// Refine the node position a bit more based on local path information
|
|
Some(ctx.world.sim()
|
|
.get_nearest_path(node_chunk_wpos)
|
|
.map_or(node_chunk_wpos, |(_, wpos, _, _)| wpos.as_())
|
|
.as_::<f32>())
|
|
} else {
|
|
None
|
|
}
|
|
}, speed_factor, 0.0)
|
|
.boxed()
|
|
|
|
// For every track in the path we discovered between the sites...
|
|
// seq(tracks
|
|
// .path
|
|
// .into_iter()
|
|
// .enumerate()
|
|
// // ...traverse the nodes of that path.
|
|
// .map(move |(i, (track_id, reversed))| now(move |ctx| {
|
|
// let track_len = ctx.world.civs().tracks.get(track_id).path().len();
|
|
// // Tracks can be traversed backward (i.e: from end to beginning). Account for this.
|
|
// seq(if reversed {
|
|
// Either::Left((0..track_len).rev())
|
|
// } else {
|
|
// Either::Right(0..track_len)
|
|
// }
|
|
// .enumerate()
|
|
// .map(move |(i, node_idx)| now(move |ctx| {
|
|
// // Find the centre of the track node's chunk
|
|
// let node_chunk_wpos = TerrainChunkSize::center_wpos(ctx.world
|
|
// .civs()
|
|
// .tracks
|
|
// .get(track_id)
|
|
// .path()
|
|
// .nodes[node_idx]);
|
|
|
|
// // Refine the node position a bit more based on local path information
|
|
// let node_wpos = ctx.world.sim()
|
|
// .get_nearest_path(node_chunk_wpos)
|
|
// .map_or(node_chunk_wpos, |(_, wpos, _, _)| wpos.as_());
|
|
|
|
// // Walk toward the node
|
|
// goto_2d(node_wpos.as_(), 1.0, 8.0)
|
|
// .debug(move || format!("traversing track node ({}/{})", i + 1, track_len))
|
|
// })))
|
|
// })
|
|
// .debug(move || format!("travel via track {:?} ({}/{})", track_id, i + 1, track_count))))
|
|
// .boxed()
|
|
} else if let Some(site) = sites.get(tgt_site) {
|
|
// If all else fails, just walk toward the target site in a straight line
|
|
travel_to_point(site.wpos.map(|e| e as f32 + 0.5), speed_factor).boxed()
|
|
} else {
|
|
// 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))
|
|
.map(|_| ())
|
|
}
|
|
|
|
// Seconds
|
|
fn timeout(time: f64) -> impl FnMut(&mut NpcCtx) -> bool + Clone + Send + Sync {
|
|
let mut timeout = None;
|
|
move |ctx| ctx.time.0 > *timeout.get_or_insert(ctx.time.0 + time)
|
|
}
|
|
|
|
fn socialize() -> impl Action {
|
|
now(|ctx| {
|
|
// Skip most socialising actions if we're not loaded
|
|
if matches!(ctx.npc.mode, SimulationMode::Loaded) && ctx.rng.gen_bool(0.002) {
|
|
if ctx.rng.gen_bool(0.15) {
|
|
return Either::Left(
|
|
just(|ctx| ctx.controller.do_dance())
|
|
.repeat()
|
|
.stop_if(timeout(6.0))
|
|
.debug(|| "dancing")
|
|
.map(|_| ())
|
|
.boxed(),
|
|
);
|
|
} else if let Some(other) = ctx
|
|
.state
|
|
.data()
|
|
.npcs
|
|
.nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0)
|
|
.choose(&mut ctx.rng)
|
|
{
|
|
return Either::Left(
|
|
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(|_| ())
|
|
.boxed(),
|
|
);
|
|
}
|
|
}
|
|
|
|
Either::Right(idle())
|
|
})
|
|
}
|
|
|
|
fn adventure() -> impl Action {
|
|
choose(|ctx| {
|
|
// Choose a random site that's fairly close by
|
|
if let Some(tgt_site) = ctx
|
|
.state
|
|
.data()
|
|
.sites
|
|
.iter()
|
|
.filter(|(site_id, site)| {
|
|
// Only path toward towns
|
|
matches!(
|
|
site.world_site.map(|ws| &ctx.index.sites.get(ws).kind),
|
|
Some(
|
|
SiteKind::Refactor(_)
|
|
| SiteKind::CliffTown(_)
|
|
| SiteKind::SavannahPit(_)
|
|
| SiteKind::DesertCity(_)
|
|
),
|
|
) && ctx.npc.current_site.map_or(true, |cs| *site_id != cs)
|
|
&& ctx.rng.gen_bool(0.25)
|
|
})
|
|
.min_by_key(|(_, site)| site.wpos.as_().distance(ctx.npc.wpos.xy()) as i32)
|
|
.map(|(site_id, _)| site_id)
|
|
{
|
|
let wait_time = if matches!(ctx.npc.profession, Some(Profession::Merchant)) {
|
|
60.0 * 15.0
|
|
} else {
|
|
60.0 * 3.0
|
|
};
|
|
let site_name = ctx.state.data().sites[tgt_site].world_site
|
|
.map(|ws| ctx.index.sites.get(ws).name().to_string())
|
|
.unwrap_or_default();
|
|
// Travel to the site
|
|
important(just(move |ctx| ctx.controller.say(None, Content::localized_with_args("npc-speech-moving_on", [("site", site_name.clone())])))
|
|
.then(travel_to_site(tgt_site, 0.6))
|
|
// Stop for a few minutes
|
|
.then(villager(tgt_site).repeat().stop_if(timeout(wait_time)))
|
|
.map(|_| ())
|
|
.boxed(),
|
|
)
|
|
} else {
|
|
casual(finish().boxed())
|
|
}
|
|
})
|
|
.debug(move || "adventure")
|
|
}
|
|
|
|
fn gather_ingredients() -> impl Action {
|
|
just(|ctx| {
|
|
ctx.controller.do_gather(
|
|
&[
|
|
ChunkResource::Fruit,
|
|
ChunkResource::Mushroom,
|
|
ChunkResource::Plant,
|
|
][..],
|
|
)
|
|
})
|
|
.debug(|| "gather ingredients")
|
|
}
|
|
|
|
fn hunt_animals() -> impl Action {
|
|
just(|ctx| ctx.controller.do_hunt_animals()).debug(|| "hunt_animals")
|
|
}
|
|
|
|
fn find_forest(ctx: &mut NpcCtx) -> Option<Vec2<f32>> {
|
|
let chunk_pos = ctx.npc.wpos.xy().as_().wpos_to_cpos();
|
|
Spiral2d::new()
|
|
.skip(ctx.rng.gen_range(1..=8))
|
|
.take(49)
|
|
.map(|rpos| chunk_pos + rpos)
|
|
.find(|cpos| {
|
|
ctx.world
|
|
.sim()
|
|
.get(*cpos)
|
|
.map_or(false, |c| c.tree_density > 0.75 && c.surface_veg > 0.5)
|
|
})
|
|
.map(|chunk| TerrainChunkSize::center_wpos(chunk).as_())
|
|
}
|
|
|
|
fn choose_plaza(ctx: &mut NpcCtx, site: SiteId) -> Option<Vec2<f32>> {
|
|
ctx.state
|
|
.data()
|
|
.sites
|
|
.get(site)
|
|
.and_then(|site| ctx.index.sites.get(site.world_site?).site2())
|
|
.and_then(|site2| {
|
|
let plaza = &site2.plots[site2.plazas().choose(&mut ctx.rng)?];
|
|
let tile = plaza
|
|
.tiles()
|
|
.choose(&mut ctx.rng)
|
|
.unwrap_or_else(|| plaza.root_tile());
|
|
Some(site2.tile_center_wpos(tile).as_())
|
|
})
|
|
}
|
|
|
|
fn villager(visiting_site: SiteId) -> impl Action {
|
|
choose(move |ctx| {
|
|
// 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)
|
|
{
|
|
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())]))
|
|
}
|
|
})
|
|
.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))
|
|
{
|
|
return important(
|
|
now(move |ctx| {
|
|
if let Some(house_wpos) = ctx
|
|
.state
|
|
.data()
|
|
.sites
|
|
.get(visiting_site)
|
|
.and_then(|site| ctx.index.sites.get(site.world_site?).site2())
|
|
.and_then(|site2| {
|
|
// Find a house in the site we're visiting
|
|
let house = site2
|
|
.plots()
|
|
.filter(|p| matches!(p.kind(), PlotKind::House(_)))
|
|
.choose(&mut ctx.rng)?;
|
|
Some(site2.tile_center_wpos(house.root_tile()).as_())
|
|
})
|
|
{
|
|
just(|ctx| {
|
|
ctx.controller
|
|
.say(None, Content::localized("npc-speech-night_time"))
|
|
})
|
|
.then(travel_to_point(house_wpos, 0.65))
|
|
.debug(|| "walk to house")
|
|
.then(socialize().repeat().debug(|| "wait in house"))
|
|
.stop_if(|ctx| DayPeriod::from(ctx.time_of_day.0).is_light())
|
|
.then(just(|ctx| {
|
|
ctx.controller
|
|
.say(None, Content::localized("npc-speech-day_time"))
|
|
}))
|
|
.map(|_| ())
|
|
.boxed()
|
|
} else {
|
|
finish().boxed()
|
|
}
|
|
})
|
|
.debug(|| "find somewhere to sleep"),
|
|
);
|
|
// Villagers with roles should perform those roles
|
|
} else if matches!(ctx.npc.profession, Some(Profession::Herbalist)) && ctx.rng.gen_bool(0.8)
|
|
{
|
|
if let Some(forest_wpos) = find_forest(ctx) {
|
|
return casual(
|
|
travel_to_point(forest_wpos, 0.5)
|
|
.debug(|| "walk to forest")
|
|
.then({
|
|
let wait_time = ctx.rng.gen_range(10.0..30.0);
|
|
gather_ingredients().repeat().stop_if(timeout(wait_time))
|
|
})
|
|
.map(|_| ()),
|
|
);
|
|
}
|
|
} else if matches!(ctx.npc.profession, Some(Profession::Hunter)) && ctx.rng.gen_bool(0.8) {
|
|
if let Some(forest_wpos) = find_forest(ctx) {
|
|
return casual(
|
|
just(|ctx| {
|
|
ctx.controller
|
|
.say(None, Content::localized("npc-speech-start_hunting"))
|
|
})
|
|
.then(travel_to_point(forest_wpos, 0.75))
|
|
.debug(|| "walk to forest")
|
|
.then({
|
|
let wait_time = ctx.rng.gen_range(30.0..60.0);
|
|
hunt_animals().repeat().stop_if(timeout(wait_time))
|
|
})
|
|
.map(|_| ()),
|
|
);
|
|
}
|
|
} 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 casual(
|
|
travel_to_point(plaza_wpos, 0.4)
|
|
.debug(|| "patrol")
|
|
.interrupt_with(|ctx| {
|
|
if ctx.rng.gen_bool(0.0003) {
|
|
Some(just(move |ctx| {
|
|
ctx.controller
|
|
.say(None, Content::localized("npc-speech-guard_thought"))
|
|
}))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.map(|_| ()),
|
|
);
|
|
}
|
|
} else if matches!(ctx.npc.profession, Some(Profession::Merchant)) && ctx.rng.gen_bool(0.8)
|
|
{
|
|
return casual(
|
|
just(|ctx| {
|
|
// Try to direct our speech at nearby actors, if there are any
|
|
let (target, phrase) = if ctx.rng.gen_bool(0.3) && let Some(other) = ctx
|
|
.state
|
|
.data()
|
|
.npcs
|
|
.nearby(Some(ctx.npc_id), ctx.npc.wpos, 8.0)
|
|
.choose(&mut ctx.rng)
|
|
{
|
|
(Some(other), "npc-speech-merchant_sell_directed")
|
|
} else {
|
|
// Otherwise, resort to generic expressions
|
|
(None, "npc-speech-merchant_sell_undirected")
|
|
};
|
|
|
|
ctx.controller.say(target, Content::localized(phrase));
|
|
})
|
|
.then(idle().repeat().stop_if(timeout(8.0)))
|
|
.repeat()
|
|
.stop_if(timeout(60.0))
|
|
.debug(|| "sell wares")
|
|
.map(|_| ()),
|
|
);
|
|
}
|
|
|
|
// If nothing else needs doing, walk between plazas and socialize
|
|
casual(now(move |ctx| {
|
|
// Choose a plaza in the site we're visiting to walk to
|
|
if let Some(plaza_wpos) = choose_plaza(ctx, visiting_site) {
|
|
// Walk to the plaza...
|
|
Either::Left(travel_to_point(plaza_wpos, 0.5)
|
|
.debug(|| "walk to plaza"))
|
|
} else {
|
|
// No plazas? :(
|
|
Either::Right(finish())
|
|
}
|
|
// ...then socialize for some time before moving on
|
|
.then(socialize()
|
|
.repeat()
|
|
.stop_if(timeout(ctx.rng.gen_range(30.0..90.0)))
|
|
.debug(|| "wait at plaza"))
|
|
.map(|_| ())
|
|
}))
|
|
})
|
|
.debug(move || format!("villager at site {:?}", visiting_site))
|
|
}
|
|
|
|
/*
|
|
fn follow(npc: NpcId, distance: f32) -> impl Action {
|
|
const STEP_DIST: f32 = 1.0;
|
|
now(move |ctx| {
|
|
if let Some(npc) = ctx.state.data().npcs.get(npc) {
|
|
let d = npc.wpos.xy() - ctx.npc.wpos.xy();
|
|
let len = d.magnitude();
|
|
let dir = d / len;
|
|
let wpos = ctx.npc.wpos.xy() + dir * STEP_DIST.min(len - distance);
|
|
goto_2d(wpos, 1.0, distance).boxed()
|
|
} else {
|
|
// The npc we're trying to follow doesn't exist.
|
|
finish().boxed()
|
|
}
|
|
})
|
|
.repeat()
|
|
.debug(move || format!("Following npc({npc:?})"))
|
|
.map(|_| {})
|
|
}
|
|
*/
|
|
|
|
fn pilot(ship: common::comp::ship::Body) -> impl Action {
|
|
// Travel between different towns in a straight line
|
|
now(move |ctx| {
|
|
let data = &*ctx.state.data();
|
|
let site = data
|
|
.sites
|
|
.iter()
|
|
.filter(|(id, _)| Some(*id) != ctx.npc.current_site)
|
|
.filter(|(_, site)| {
|
|
site.world_site
|
|
.and_then(|site| ctx.index.sites.get(site).kind.convert_to_meta())
|
|
.map_or(false, |meta| matches!(meta, SiteKindMeta::Settlement(_)))
|
|
})
|
|
.choose(&mut ctx.rng);
|
|
if let Some((_id, site)) = site {
|
|
Either::Right(goto_2d(site.wpos.as_(), 1.0, 20.0, ship.flying_height()))
|
|
} else {
|
|
Either::Left(finish())
|
|
}
|
|
})
|
|
.repeat()
|
|
.map(|_| ())
|
|
}
|
|
|
|
fn captain() -> impl Action {
|
|
// For now just randomly travel the sea
|
|
now(|ctx| {
|
|
let chunk = ctx.npc.wpos.xy().as_().wpos_to_cpos();
|
|
if let Some(chunk) = NEIGHBORS
|
|
.into_iter()
|
|
.map(|neighbor| chunk + neighbor)
|
|
.filter(|neighbor| {
|
|
ctx.world
|
|
.sim()
|
|
.get(*neighbor)
|
|
.map_or(false, |c| c.river.river_kind.is_some())
|
|
})
|
|
.choose(&mut ctx.rng)
|
|
{
|
|
let wpos = TerrainChunkSize::center_wpos(chunk);
|
|
let wpos = wpos.as_().with_z(
|
|
ctx.world
|
|
.sim()
|
|
.get_interpolated(wpos, |chunk| chunk.water_alt)
|
|
.unwrap_or(0.0),
|
|
);
|
|
goto(wpos, 0.7, 5.0, 0.0).boxed()
|
|
} else {
|
|
idle().boxed()
|
|
}
|
|
})
|
|
.repeat()
|
|
.map(|_| ())
|
|
}
|
|
|
|
fn check_inbox(ctx: &mut NpcCtx) -> Option<impl Action> {
|
|
loop {
|
|
match ctx.inbox.pop_front() {
|
|
Some(report_id) if !ctx.known_reports.contains(&report_id) => {
|
|
#[allow(clippy::single_match)]
|
|
match ctx.state.data().reports.get(report_id).map(|r| r.kind) {
|
|
Some(ReportKind::Death { killer, .. }) => {
|
|
// 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: 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"
|
|
};
|
|
ctx.known_reports.insert(report_id);
|
|
break Some(just(move |ctx| {
|
|
ctx.controller.say(killer, Content::localized(phrase))
|
|
}));
|
|
},
|
|
None => {}, // Stale report, ignore
|
|
}
|
|
},
|
|
Some(_) => {}, // Reports we already know of are ignored
|
|
None => break None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check_for_enemies(ctx: &mut NpcCtx) -> Option<impl Action> {
|
|
// TODO: Instead of checking all nearby actors every tick, it would be more
|
|
// effective to have the actor grid generate a per-tick diff so that we only
|
|
// need to check new actors in the local area. Be careful though:
|
|
// implementing this means accounting for changes in sentiment (that could
|
|
// suddenly make a nearby actor an enemy) as well as variable NPC tick
|
|
// rates!
|
|
ctx.state
|
|
.data()
|
|
.npcs
|
|
.nearby(Some(ctx.npc_id), ctx.npc.wpos, 24.0)
|
|
.find(|actor| ctx.sentiments.toward(*actor).is(Sentiment::ENEMY))
|
|
.map(|enemy| just(move |ctx| ctx.controller.attack(enemy)))
|
|
}
|
|
|
|
fn react_to_events(ctx: &mut NpcCtx) -> Option<impl Action> {
|
|
check_inbox(ctx)
|
|
.map(|action| action.boxed())
|
|
.or_else(|| check_for_enemies(ctx).map(|action| action.boxed()))
|
|
}
|
|
|
|
fn humanoid() -> impl Action {
|
|
choose(|ctx| {
|
|
if let Some(riding) = &ctx.npc.riding {
|
|
if riding.steering {
|
|
if let Some(vehicle) = ctx.state.data().npcs.vehicles.get(riding.vehicle) {
|
|
match vehicle.body {
|
|
common::comp::ship::Body::DefaultAirship
|
|
| common::comp::ship::Body::AirBalloon => important(pilot(vehicle.body)),
|
|
common::comp::ship::Body::SailBoat | common::comp::ship::Body::Galleon => {
|
|
important(captain())
|
|
},
|
|
_ => casual(idle()),
|
|
}
|
|
} else {
|
|
casual(finish())
|
|
}
|
|
} else {
|
|
important(socialize())
|
|
}
|
|
} else {
|
|
let action = if matches!(
|
|
ctx.npc.profession,
|
|
Some(Profession::Adventurer(_) | Profession::Merchant)
|
|
) {
|
|
adventure().boxed()
|
|
} else if let Some(home) = ctx.npc.home {
|
|
villager(home).boxed()
|
|
} else {
|
|
idle().boxed() // Homeless
|
|
};
|
|
|
|
casual(action.interrupt_with(react_to_events))
|
|
}
|
|
})
|
|
}
|
|
|
|
fn bird_large() -> impl Action {
|
|
choose(|ctx| {
|
|
let data = ctx.state.data();
|
|
if let Some(home) = ctx.npc.home {
|
|
let is_home = ctx.npc.current_site.map_or(false, |site| home == site);
|
|
if is_home {
|
|
if let Some((_, site)) = data
|
|
.sites
|
|
.iter()
|
|
.filter(|(id, site)| {
|
|
*id != home
|
|
&& site.world_site.map_or(false, |site| {
|
|
matches!(ctx.index.sites.get(site).kind, SiteKind::Dungeon(_))
|
|
})
|
|
})
|
|
.choose(&mut ctx.rng)
|
|
{
|
|
casual(goto_2d(
|
|
site.wpos.as_::<f32>(),
|
|
1.0,
|
|
20.0,
|
|
ctx.npc.body.flying_height(),
|
|
))
|
|
} else {
|
|
casual(idle())
|
|
}
|
|
} else if let Some(site) = data.sites.get(home) {
|
|
casual(goto_2d(
|
|
site.wpos.as_::<f32>(),
|
|
1.0,
|
|
20.0,
|
|
ctx.npc.body.flying_height(),
|
|
))
|
|
} else {
|
|
casual(idle())
|
|
}
|
|
} else {
|
|
casual(idle())
|
|
}
|
|
})
|
|
}
|
|
|
|
fn think() -> impl Action {
|
|
choose(|ctx| match ctx.npc.body {
|
|
common::comp::Body::Humanoid(_) => casual(humanoid()),
|
|
common::comp::Body::BirdLarge(_) => casual(bird_large()),
|
|
_ => casual(socialize()),
|
|
})
|
|
}
|