diff --git a/common/src/rtsim.rs b/common/src/rtsim.rs index 0d58760f19..1c0fbc55e6 100644 --- a/common/src/rtsim.rs +++ b/common/src/rtsim.rs @@ -214,6 +214,8 @@ pub enum NpcActivity { /// (travel_to, speed_factor) Goto(Vec3, f32), Gather(&'static [ChunkResource]), + // TODO: Generalise to other entities? What kinds of animals? + HuntAnimals, } #[derive(Clone, Copy, Debug)] diff --git a/rtsim/src/data/npc.rs b/rtsim/src/data/npc.rs index cbfc8fe258..3c4fcf5099 100644 --- a/rtsim/src/data/npc.rs +++ b/rtsim/src/data/npc.rs @@ -62,6 +62,8 @@ impl Controller { self.activity = Some(NpcActivity::Gather(resources)); } + pub fn do_hunt_animals(&mut self) { self.activity = Some(NpcActivity::HuntAnimals); } + pub fn do_greet(&mut self, actor: Actor) { self.actions.push(NpcAction::Greet(actor)); } } diff --git a/rtsim/src/rule/npc_ai.rs b/rtsim/src/rule/npc_ai.rs index 7ca787d5f8..3e36b81355 100644 --- a/rtsim/src/rule/npc_ai.rs +++ b/rtsim/src/rule/npc_ai.rs @@ -528,6 +528,25 @@ fn gather_ingredients() -> impl Action { .debug(|| "gather ingredients") } +fn hunt_animals() -> impl Action { + just(|ctx| ctx.controller.do_hunt_animals()).debug(|| "hunt_animals") +} + +fn find_forest(ctx: &NpcCtx) -> Option> { + let chunk_pos = ctx.npc.wpos.xy().as_() / TerrainChunkSize::RECT_SIZE.as_(); + Spiral2d::new() + .skip(thread_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 villager(visiting_site: SiteId) -> impl Action { choose(move |ctx| { /* @@ -586,20 +605,9 @@ fn villager(visiting_site: SiteId) -> impl Action { ); // Villagers with roles should perform those roles } else if matches!(ctx.npc.profession, Some(Profession::Herbalist)) { - let chunk_pos = ctx.npc.wpos.xy().as_() / TerrainChunkSize::RECT_SIZE.as_(); - if let Some(tree_chunk) = Spiral2d::new() - .skip(thread_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) - }) - { + if let Some(forest_wpos) = find_forest(ctx) { return important( - travel_to_point(TerrainChunkSize::center_wpos(tree_chunk).as_()) + travel_to_point(forest_wpos) .debug(|| "walk to forest") .then({ let wait_time = thread_rng().gen_range(10.0..30.0); @@ -608,6 +616,18 @@ fn villager(visiting_site: SiteId) -> impl Action { .map(|_| ()), ); } + } else if matches!(ctx.npc.profession, Some(Profession::Hunter)) { + if let Some(forest_wpos) = find_forest(ctx) { + return important( + travel_to_point(forest_wpos) + .debug(|| "walk to forest") + .then({ + let wait_time = thread_rng().gen_range(30.0..60.0); + hunt_animals().repeat().stop_if(timeout(wait_time)) + }) + .map(|_| ()), + ); + } } // If nothing else needs doing, walk between plazas and socialize diff --git a/rtsim/src/rule/simulate_npcs.rs b/rtsim/src/rule/simulate_npcs.rs index 68c15c7915..cc78c17142 100644 --- a/rtsim/src/rule/simulate_npcs.rs +++ b/rtsim/src/rule/simulate_npcs.rs @@ -246,7 +246,11 @@ impl Rule for SimulateNpcs { } }, // When riding, other actions are disabled - Some(NpcActivity::Goto(_, _) | NpcActivity::Gather(_)) => {}, + Some( + NpcActivity::Goto(_, _) + | NpcActivity::Gather(_) + | NpcActivity::HuntAnimals, + ) => {}, None => {}, } npc.wpos = vehicle.wpos; @@ -272,7 +276,7 @@ impl Rule for SimulateNpcs { .with_z(0.0); } }, - Some(NpcActivity::Gather(_)) => { + Some(NpcActivity::Gather(_) | NpcActivity::HuntAnimals) => { // TODO: Maybe they should walk around randomly // when gathering resources? }, diff --git a/server/agent/src/action_nodes.rs b/server/agent/src/action_nodes.rs index 8c1169f27d..f555cc887f 100644 --- a/server/agent/src/action_nodes.rs +++ b/server/agent/src/action_nodes.rs @@ -161,6 +161,7 @@ impl<'a> AgentData<'a> { agent: &mut Agent, controller: &mut Controller, read_data: &ReadData, + event_emitter: &mut Emitter, rng: &mut impl Rng, ) { enum ActionTimers { @@ -213,249 +214,267 @@ impl<'a> AgentData<'a> { } agent.action_state.timers[ActionTimers::TimerIdle as usize] = 0.0; - match agent.rtsim_controller.activity { - Some(NpcActivity::Goto(travel_to, speed_factor)) => { - // If it has an rtsim destination and can fly, then it should. - // If it is flying and bumps something above it, then it should move down. - if self.traversal_config.can_fly - && !read_data - .terrain - .ray(self.pos.0, self.pos.0 + (Vec3::unit_z() * 3.0)) - .until(Block::is_solid) - .cast() - .1 - .map_or(true, |b| b.is_some()) - { - controller.push_basic_input(InputKind::Fly); - } else { - controller.push_cancel_input(InputKind::Fly) - } - - let chase_tgt = read_data - .terrain - .try_find_space(travel_to.as_()) - .map(|pos| pos.as_()) - .unwrap_or(travel_to); - - if let Some((bearing, speed)) = agent.chaser.chase( - &*read_data.terrain, - self.pos.0, - self.vel.0, - chase_tgt, - TraversalConfig { - min_tgt_dist: 1.25, - ..self.traversal_config - }, - ) { - controller.inputs.move_dir = - bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) - * speed.min(speed_factor); - self.jump_if(bearing.z > 1.5 || self.traversal_config.can_fly, controller); - controller.inputs.climb = Some(comp::Climb::Up); - //.filter(|_| bearing.z > 0.1 || self.physics_state.in_liquid().is_some()); - - let height_offset = bearing.z - + if self.traversal_config.can_fly { - // NOTE: costs 4 us (imbris) - let obstacle_ahead = read_data - .terrain - .ray( - self.pos.0 + Vec3::unit_z(), - self.pos.0 - + bearing.try_normalized().unwrap_or_else(Vec3::unit_y) - * 80.0 - + Vec3::unit_z(), - ) - .until(Block::is_solid) - .cast() - .1 - .map_or(true, |b| b.is_some()); - - let mut ground_too_close = self - .body - .map(|body| { - #[cfg(feature = "worldgen")] - let height_approx = self.pos.0.z - - read_data - .world - .sim() - .get_alt_approx(self.pos.0.xy().map(|x: f32| x as i32)) - .unwrap_or(0.0); - #[cfg(not(feature = "worldgen"))] - let height_approx = self.pos.0.z; - - height_approx < body.flying_height() - }) - .unwrap_or(false); - - const NUM_RAYS: usize = 5; - - // NOTE: costs 15-20 us (imbris) - for i in 0..=NUM_RAYS { - let magnitude = self.body.map_or(20.0, |b| b.flying_height()); - // Lerp between a line straight ahead and straight down to detect a - // wedge of obstacles we might fly into (inclusive so that both - // vectors are sampled) - if let Some(dir) = Lerp::lerp( - -Vec3::unit_z(), - Vec3::new(bearing.x, bearing.y, 0.0), - i as f32 / NUM_RAYS as f32, - ) - .try_normalized() - { - ground_too_close |= read_data - .terrain - .ray(self.pos.0, self.pos.0 + magnitude * dir) - .until(|b: &Block| b.is_solid() || b.is_liquid()) - .cast() - .1 - .map_or(false, |b| b.is_some()) - } - } - - if obstacle_ahead || ground_too_close { - 5.0 //fly up when approaching obstacles - } else { - -2.0 - } //flying things should slowly come down from the stratosphere - } else { - 0.05 //normal land traveller offset - }; - if let Some(pid) = agent.position_pid_controller.as_mut() { - pid.sp = self.pos.0.z + height_offset * Vec3::unit_z(); - controller.inputs.move_z = pid.calc_err(); - } else { - controller.inputs.move_z = height_offset; - } - // Put away weapon - if rng.gen_bool(0.1) - && matches!( - read_data.char_states.get(*self.entity), - Some(CharacterState::Wielding(_)) - ) + 'activity: { + match agent.rtsim_controller.activity { + Some(NpcActivity::Goto(travel_to, speed_factor)) => { + // If it has an rtsim destination and can fly, then it should. + // If it is flying and bumps something above it, then it should move down. + if self.traversal_config.can_fly + && !read_data + .terrain + .ray(self.pos.0, self.pos.0 + (Vec3::unit_z() * 3.0)) + .until(Block::is_solid) + .cast() + .1 + .map_or(true, |b| b.is_some()) { - controller.push_action(ControlAction::Unwield); + controller.push_basic_input(InputKind::Fly); + } else { + controller.push_cancel_input(InputKind::Fly) } - } - }, - Some(NpcActivity::Gather(resources)) => { - // TODO: Implement - controller.push_action(ControlAction::Dance); - }, - None => { - // Bats should fly - // Use a proportional controller as the bouncing effect mimics bat flight - if self.traversal_config.can_fly - && self - .inventory - .equipped(EquipSlot::ActiveMainhand) - .as_ref() - .map_or(false, |item| { - item.ability_spec().map_or(false, |a_s| match &*a_s { - AbilitySpec::Custom(spec) => { - matches!( - spec.as_str(), - "Simple Flying Melee" - | "Flame Wyvern" - | "Frost Wyvern" - | "Cloud Wyvern" - | "Sea Wyvern" - | "Weald Wyvern" - ) - }, - _ => false, - }) - }) - { - // Bats don't like the ground, so make sure they are always flying - controller.push_basic_input(InputKind::Fly); - // Use a proportional controller with a coefficient of 1.0 to - // maintain altitude - let alt = read_data - .terrain - .ray(self.pos.0, self.pos.0 - (Vec3::unit_z() * 7.0)) - .until(Block::is_solid) - .cast() - .0; - let set_point = 5.0; - let error = set_point - alt; - controller.inputs.move_z = error; - // If on the ground, jump - if self.physics_state.on_ground.is_some() { - controller.push_basic_input(InputKind::Jump); - } - } - agent.bearing += Vec2::new(rng.gen::() - 0.5, rng.gen::() - 0.5) * 0.1 - - agent.bearing * 0.003 - - agent.patrol_origin.map_or(Vec2::zero(), |patrol_origin| { - (self.pos.0 - patrol_origin).xy() * 0.0002 - }); - // Stop if we're too close to a wall - // or about to walk off a cliff - // NOTE: costs 1 us (imbris) <- before cliff raycast added - agent.bearing *= 0.1 - + if read_data + let chase_tgt = read_data + .terrain + .try_find_space(travel_to.as_()) + .map(|pos| pos.as_()) + .unwrap_or(travel_to); + + if let Some((bearing, speed)) = agent.chaser.chase( + &*read_data.terrain, + self.pos.0, + self.vel.0, + chase_tgt, + TraversalConfig { + min_tgt_dist: 1.25, + ..self.traversal_config + }, + ) { + controller.inputs.move_dir = + bearing.xy().try_normalized().unwrap_or_else(Vec2::zero) + * speed.min(speed_factor); + self.jump_if(bearing.z > 1.5 || self.traversal_config.can_fly, controller); + controller.inputs.climb = Some(comp::Climb::Up); + //.filter(|_| bearing.z > 0.1 || self.physics_state.in_liquid().is_some()); + + let height_offset = bearing.z + + if self.traversal_config.can_fly { + // NOTE: costs 4 us (imbris) + let obstacle_ahead = read_data + .terrain + .ray( + self.pos.0 + Vec3::unit_z(), + self.pos.0 + + bearing.try_normalized().unwrap_or_else(Vec3::unit_y) + * 80.0 + + Vec3::unit_z(), + ) + .until(Block::is_solid) + .cast() + .1 + .map_or(true, |b| b.is_some()); + + let mut ground_too_close = self + .body + .map(|body| { + #[cfg(feature = "worldgen")] + let height_approx = self.pos.0.z + - read_data + .world + .sim() + .get_alt_approx( + self.pos.0.xy().map(|x: f32| x as i32), + ) + .unwrap_or(0.0); + #[cfg(not(feature = "worldgen"))] + let height_approx = self.pos.0.z; + + height_approx < body.flying_height() + }) + .unwrap_or(false); + + const NUM_RAYS: usize = 5; + + // NOTE: costs 15-20 us (imbris) + for i in 0..=NUM_RAYS { + let magnitude = self.body.map_or(20.0, |b| b.flying_height()); + // Lerp between a line straight ahead and straight down to + // detect a + // wedge of obstacles we might fly into (inclusive so that both + // vectors are sampled) + if let Some(dir) = Lerp::lerp( + -Vec3::unit_z(), + Vec3::new(bearing.x, bearing.y, 0.0), + i as f32 / NUM_RAYS as f32, + ) + .try_normalized() + { + ground_too_close |= read_data + .terrain + .ray(self.pos.0, self.pos.0 + magnitude * dir) + .until(|b: &Block| b.is_solid() || b.is_liquid()) + .cast() + .1 + .map_or(false, |b| b.is_some()) + } + } + + if obstacle_ahead || ground_too_close { + 5.0 //fly up when approaching obstacles + } else { + -2.0 + } //flying things should slowly come down from the stratosphere + } else { + 0.05 //normal land traveller offset + }; + if let Some(pid) = agent.position_pid_controller.as_mut() { + pid.sp = self.pos.0.z + height_offset * Vec3::unit_z(); + controller.inputs.move_z = pid.calc_err(); + } else { + controller.inputs.move_z = height_offset; + } + // Put away weapon + if rng.gen_bool(0.1) + && matches!( + read_data.char_states.get(*self.entity), + Some(CharacterState::Wielding(_)) + ) + { + controller.push_action(ControlAction::Unwield); + } + } + break 'activity; // Don't fall through to idle wandering + }, + Some(NpcActivity::Gather(resources)) => { + // TODO: Implement + controller.push_action(ControlAction::Dance); + break 'activity; // Don't fall through to idle wandering + }, + Some(NpcActivity::HuntAnimals) => { + if rng.gen::() < 0.1 { + self.choose_target( + agent, + controller, + read_data, + event_emitter, + AgentData::is_hunting_animal, + ); + } + }, + None => {}, + } + + // Bats should fly + // Use a proportional controller as the bouncing effect mimics bat flight + if self.traversal_config.can_fly + && self + .inventory + .equipped(EquipSlot::ActiveMainhand) + .as_ref() + .map_or(false, |item| { + item.ability_spec().map_or(false, |a_s| match &*a_s { + AbilitySpec::Custom(spec) => { + matches!( + spec.as_str(), + "Simple Flying Melee" + | "Flame Wyvern" + | "Frost Wyvern" + | "Cloud Wyvern" + | "Sea Wyvern" + | "Weald Wyvern" + ) + }, + _ => false, + }) + }) + { + // Bats don't like the ground, so make sure they are always flying + controller.push_basic_input(InputKind::Fly); + // Use a proportional controller with a coefficient of 1.0 to + // maintain altitude + let alt = read_data + .terrain + .ray(self.pos.0, self.pos.0 - (Vec3::unit_z() * 7.0)) + .until(Block::is_solid) + .cast() + .0; + let set_point = 5.0; + let error = set_point - alt; + controller.inputs.move_z = error; + // If on the ground, jump + if self.physics_state.on_ground.is_some() { + controller.push_basic_input(InputKind::Jump); + } + } + agent.bearing += Vec2::new(rng.gen::() - 0.5, rng.gen::() - 0.5) * 0.1 + - agent.bearing * 0.003 + - agent.patrol_origin.map_or(Vec2::zero(), |patrol_origin| { + (self.pos.0 - patrol_origin).xy() * 0.0002 + }); + + // Stop if we're too close to a wall + // or about to walk off a cliff + // NOTE: costs 1 us (imbris) <- before cliff raycast added + agent.bearing *= 0.1 + + if read_data + .terrain + .ray( + self.pos.0 + Vec3::unit_z(), + self.pos.0 + + Vec3::from(agent.bearing) + .try_normalized() + .unwrap_or_else(Vec3::unit_y) + * 5.0 + + Vec3::unit_z(), + ) + .until(Block::is_solid) + .cast() + .1 + .map_or(true, |b| b.is_none()) + && read_data .terrain .ray( - self.pos.0 + Vec3::unit_z(), + self.pos.0 + + Vec3::from(agent.bearing) + .try_normalized() + .unwrap_or_else(Vec3::unit_y), self.pos.0 + Vec3::from(agent.bearing) .try_normalized() .unwrap_or_else(Vec3::unit_y) - * 5.0 - + Vec3::unit_z(), + - Vec3::unit_z() * 4.0, ) .until(Block::is_solid) .cast() - .1 - .map_or(true, |b| b.is_none()) - && read_data - .terrain - .ray( - self.pos.0 - + Vec3::from(agent.bearing) - .try_normalized() - .unwrap_or_else(Vec3::unit_y), - self.pos.0 - + Vec3::from(agent.bearing) - .try_normalized() - .unwrap_or_else(Vec3::unit_y) - - Vec3::unit_z() * 4.0, - ) - .until(Block::is_solid) - .cast() - .0 - < 3.0 - { - 0.9 - } else { - 0.0 - }; - - if agent.bearing.magnitude_squared() > 0.5f32.powi(2) { - controller.inputs.move_dir = agent.bearing * 0.65; - } - - // Put away weapon - if rng.gen_bool(0.1) - && matches!( - read_data.char_states.get(*self.entity), - Some(CharacterState::Wielding(_)) - ) + .0 + < 3.0 { - controller.push_action(ControlAction::Unwield); - } + 0.9 + } else { + 0.0 + }; - if rng.gen::() < 0.0015 { - controller.push_utterance(UtteranceKind::Calm); - } + if agent.bearing.magnitude_squared() > 0.5f32.powi(2) { + controller.inputs.move_dir = agent.bearing * 0.65; + } - // Sit - if rng.gen::() < 0.0035 { - controller.push_action(ControlAction::Sit); - } - }, + // Put away weapon + if rng.gen_bool(0.1) + && matches!( + read_data.char_states.get(*self.entity), + Some(CharacterState::Wielding(_)) + ) + { + controller.push_action(ControlAction::Unwield); + } + + if rng.gen::() < 0.0015 { + controller.push_utterance(UtteranceKind::Calm); + } + + // Sit + if rng.gen::() < 0.0035 { + controller.push_action(ControlAction::Sit); + } } } @@ -660,6 +679,7 @@ impl<'a> AgentData<'a> { controller: &mut Controller, read_data: &ReadData, event_emitter: &mut Emitter, + is_enemy: fn(&Self, EcsEntity, &ReadData) -> bool, ) { enum ActionStateTimers { TimerChooseTarget = 0, @@ -700,7 +720,7 @@ impl<'a> AgentData<'a> { let get_pos = |entity| read_data.positions.get(entity); let get_enemy = |(entity, attack_target): (EcsEntity, bool)| { if attack_target { - if self.is_enemy(entity, read_data) { + if is_enemy(self, entity, read_data) { Some((entity, true)) } else if can_ambush(entity, read_data) { controller.clone().push_utterance(UtteranceKind::Ambush); @@ -1385,12 +1405,13 @@ impl<'a> AgentData<'a> { agent: &mut Agent, controller: &mut Controller, read_data: &ReadData, + event_emitter: &mut Emitter, rng: &mut impl Rng, ) { agent.forget_old_sounds(read_data.time.0); if is_invulnerable(*self.entity, read_data) { - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); return; } @@ -1423,13 +1444,13 @@ impl<'a> AgentData<'a> { } else if self.below_flee_health(agent) || !follows_threatening_sounds { self.flee(agent, controller, &sound_pos, &read_data.terrain); } else { - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); } } else { - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); } } else { - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); } } @@ -1438,6 +1459,7 @@ impl<'a> AgentData<'a> { agent: &mut Agent, read_data: &ReadData, controller: &mut Controller, + event_emitter: &mut Emitter, rng: &mut impl Rng, ) { if let Some(Target { target, .. }) = agent.target { @@ -1467,7 +1489,7 @@ impl<'a> AgentData<'a> { Some(tgt_pos.0), )); - self.idle(agent, controller, read_data, rng); + self.idle(agent, controller, read_data, event_emitter, rng); } else { let target_data = TargetData::new(tgt_pos, target, read_data); // TODO: Reimplement this in rtsim @@ -1595,7 +1617,7 @@ impl<'a> AgentData<'a> { }) } - fn is_enemy(&self, entity: EcsEntity, read_data: &ReadData) -> bool { + pub fn is_enemy(&self, entity: EcsEntity, read_data: &ReadData) -> bool { let other_alignment = read_data.alignments.get(entity); (entity != *self.entity) @@ -1604,6 +1626,11 @@ impl<'a> AgentData<'a> { || (is_villager(self.alignment) && is_dressed_as_cultist(entity, read_data))) } + pub fn is_hunting_animal(&self, entity: EcsEntity, read_data: &ReadData) -> bool { + (entity != *self.entity) + && matches!(read_data.bodies.get(entity), Some(Body::QuadrupedSmall(_))) + } + fn should_defend(&self, entity: EcsEntity, read_data: &ReadData) -> bool { let entity_alignment = read_data.alignments.get(entity); diff --git a/server/src/sys/agent/behavior_tree.rs b/server/src/sys/agent/behavior_tree.rs index 5845ea8d0e..9f3924be17 100644 --- a/server/src/sys/agent/behavior_tree.rs +++ b/server/src/sys/agent/behavior_tree.rs @@ -437,6 +437,7 @@ fn attack_if_owner_hurt(bdata: &mut BehaviorData) -> bool { bdata.agent, bdata.read_data, bdata.controller, + bdata.event_emitter, bdata.rng, ); return true; @@ -543,12 +544,14 @@ fn handle_timed_events(bdata: &mut BehaviorData) -> bool { bdata.controller, bdata.read_data, bdata.event_emitter, + AgentData::is_enemy, ); } else { bdata.agent_data.handle_sounds_heard( bdata.agent, bdata.controller, bdata.read_data, + bdata.event_emitter, bdata.rng, ); } @@ -763,12 +766,12 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { [ActionStateBehaviorTreeTimers::TimerBehaviorTree as usize] = 0.0; agent.target = None; agent.flee_from_pos = None; - agent_data.idle(agent, controller, read_data, rng); + agent_data.idle(agent, controller, read_data, event_emitter, rng); } } else if is_dead(target, read_data) { agent_data.exclaim_relief_about_enemy_dead(agent, event_emitter); agent.target = None; - agent_data.idle(agent, controller, read_data, rng); + agent_data.idle(agent, controller, read_data, event_emitter, rng); } else if is_invulnerable(target, read_data) || stop_pursuing( dist_sqrd, @@ -780,13 +783,19 @@ fn do_combat(bdata: &mut BehaviorData) -> bool { ) { agent.target = None; - agent_data.idle(agent, controller, read_data, rng); + agent_data.idle(agent, controller, read_data, event_emitter, rng); } else { let is_time_to_retarget = read_data.time.0 - selected_at > RETARGETING_THRESHOLD_SECONDS; if !in_aggro_range && is_time_to_retarget { - agent_data.choose_target(agent, controller, read_data, event_emitter); + agent_data.choose_target( + agent, + controller, + read_data, + event_emitter, + AgentData::is_enemy, + ); } if aggro_on {