start monster
This commit is contained in:
BIN
assets/audio/monster-footsteps.ogg
Normal file
BIN
assets/audio/monster-footsteps.ogg
Normal file
Binary file not shown.
@@ -2,6 +2,7 @@
|
||||
"lebron": File (path: "images/KingLebron.png"),
|
||||
"flash_hold_4": File (path: "images/pixelart/Flashlight_hold_4.png"),
|
||||
"flash_hold_4_pressed": File (path: "images/pixelart/Flashlight_click_4.png"),
|
||||
"monster_footsteps": File (path: "audio/monster-footsteps.ogg"),
|
||||
"house": File (path: "meshes/House.glb"),
|
||||
"flashlight_click": File (path: "audio/flashlight-switch.ogg"),
|
||||
"library": Folder (
|
||||
|
||||
@@ -31,6 +31,8 @@ pub(super) fn plugin(app: &mut App) {
|
||||
pub(crate) struct AudioAssets {
|
||||
#[asset(key = "flashlight_click")]
|
||||
pub(crate) flash_click: Handle<AudioSource>,
|
||||
#[asset(key = "monster_footsteps")]
|
||||
pub(crate) monster_footsteps: Handle<AudioSource>,
|
||||
}
|
||||
|
||||
#[derive(AssetCollection, Resource, Clone)]
|
||||
|
||||
@@ -10,6 +10,7 @@ mod main_menu;
|
||||
mod player;
|
||||
mod util;
|
||||
mod debugging;
|
||||
mod monster;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
@@ -22,12 +23,13 @@ fn main() {
|
||||
RapierPhysicsPlugin::<NoUserData>::default(),
|
||||
// RapierDebugRenderPlugin::default(),
|
||||
player::plugin,
|
||||
monster::plugin,
|
||||
// debugging::plugin
|
||||
))
|
||||
.init_state::<GameState>()
|
||||
.insert_resource(AmbientLight {
|
||||
color: Color::srgba(0.8, 0.8, 1.0, 1.0),
|
||||
// brightness: 10.0,
|
||||
// brightness: 11.0,
|
||||
brightness: 80.0,
|
||||
})
|
||||
.add_systems(OnEnter(GameState::Playing), setup)
|
||||
|
||||
355
src/monster.rs
Normal file
355
src/monster.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::{
|
||||
prelude::*,
|
||||
time::Stopwatch,
|
||||
};
|
||||
use bevy_kira_audio::{prelude::Volume, Audio, AudioControl, AudioTween};
|
||||
use bevy_rapier3d::prelude::*;
|
||||
use rand::prelude::*;
|
||||
|
||||
use crate::{
|
||||
asset_loading::AudioAssets,
|
||||
player::Player,
|
||||
GameState
|
||||
};
|
||||
|
||||
// Monster states and behavior configuration
|
||||
#[derive(Debug, Component)]
|
||||
pub struct Monster {
|
||||
pub speed: f32,
|
||||
pub detection_range: f32,
|
||||
pub state_timer: Timer,
|
||||
pub footstep_timer: Timer,
|
||||
pub state: MonsterState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MonsterState {
|
||||
Lurking,
|
||||
Hunting,
|
||||
Wandering,
|
||||
Dormant,
|
||||
}
|
||||
|
||||
impl Default for Monster {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
speed: 2.0,
|
||||
detection_range: 15.0,
|
||||
state_timer: Timer::from_seconds(1.0, TimerMode::Once),
|
||||
footstep_timer: Timer::from_seconds(3.0, TimerMode::Repeating),
|
||||
state: MonsterState::Dormant,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Component, Default)]
|
||||
pub struct MonsterPathfinding {
|
||||
pub current_target: Option<Vec3>,
|
||||
pub wander_target_timer: Timer,
|
||||
}
|
||||
|
||||
#[derive(Debug, Component, Default)]
|
||||
pub struct DangerIndicator {
|
||||
pub last_sound_timer: Stopwatch,
|
||||
}
|
||||
|
||||
pub fn plugin(app: &mut App) {
|
||||
app
|
||||
.add_systems(OnEnter(GameState::Playing), spawn_monster)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_monster_state,
|
||||
move_monster,
|
||||
play_monster_sounds,
|
||||
).run_if(in_state(GameState::Playing)),
|
||||
);
|
||||
}
|
||||
|
||||
fn spawn_monster(
|
||||
mut commands: Commands,
|
||||
// mut meshes: ResMut<Assets<Mesh>>,
|
||||
// mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
) {
|
||||
let spawn_position = Vec3::new(20.0, 0.5, 20.0);
|
||||
|
||||
commands.spawn((
|
||||
Monster::default(),
|
||||
MonsterPathfinding {
|
||||
current_target: None,
|
||||
wander_target_timer: Timer::from_seconds(5.0, TimerMode::Repeating),
|
||||
},
|
||||
DangerIndicator::default(),
|
||||
RigidBody::Dynamic,
|
||||
Collider::capsule(Vec3::new(0.0, -0.5, 0.0), Vec3::new(0.0, 1.0, 0.0), 0.6),
|
||||
Velocity::zero(),
|
||||
LockedAxes::ROTATION_LOCKED,
|
||||
Damping {
|
||||
linear_damping: 1.0,
|
||||
angular_damping: 1.0,
|
||||
},
|
||||
Transform::from_translation(spawn_position),
|
||||
));
|
||||
}
|
||||
|
||||
fn update_monster_state(
|
||||
time: Res<Time>,
|
||||
mut monster_query: Query<(&mut Monster, &Transform, &mut MonsterPathfinding, &mut DangerIndicator), Without<Player>>,
|
||||
player_query: Query<&Transform, With<Player>>,
|
||||
) {
|
||||
let mut rand = rand::rng();
|
||||
|
||||
if let Ok((mut monster, monster_transform, mut pathfinding, mut danger)) = monster_query.get_single_mut() {
|
||||
let player_pos = if let Ok(player_transform) = player_query.get_single() {
|
||||
Some(player_transform.translation)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
monster.state_timer.tick(time.delta());
|
||||
|
||||
// when timer expires, potentially change state
|
||||
if monster.state_timer.just_finished() {
|
||||
let next_state = match monster.state {
|
||||
MonsterState::Dormant => {
|
||||
// 70% chance to start lurking
|
||||
if rand.random_bool(0.85) {
|
||||
MonsterState::Lurking
|
||||
} else {
|
||||
MonsterState::Dormant
|
||||
}
|
||||
},
|
||||
MonsterState::Lurking => {
|
||||
// 30% chance to start hunting, 20% to go dormant, 50% to start wandering
|
||||
let roll = rand.random_range(0.0..1.0);
|
||||
if roll < 0.3 {
|
||||
danger.last_sound_timer.reset();
|
||||
MonsterState::Hunting
|
||||
} else if roll < 0.5 {
|
||||
MonsterState::Dormant
|
||||
} else {
|
||||
MonsterState::Wandering
|
||||
}
|
||||
},
|
||||
MonsterState::Wandering => {
|
||||
// 20% chance to start hunting, 30% to go lurking, 50% to keep wandering
|
||||
let roll = rand.random_range(0.0..1.0);
|
||||
if roll < 0.2 {
|
||||
danger.last_sound_timer.reset();
|
||||
MonsterState::Hunting
|
||||
} else if roll < 0.5 {
|
||||
MonsterState::Lurking
|
||||
} else {
|
||||
MonsterState::Wandering
|
||||
}
|
||||
},
|
||||
MonsterState::Hunting => {
|
||||
// after hunting, always go dormant for a while
|
||||
MonsterState::Dormant
|
||||
}
|
||||
};
|
||||
|
||||
// set state
|
||||
monster.state = next_state.clone();
|
||||
monster.state_timer = match next_state {
|
||||
MonsterState::Dormant => Timer::from_seconds(15.0, TimerMode::Once),
|
||||
MonsterState::Lurking => Timer::from_seconds(20.0, TimerMode::Once),
|
||||
MonsterState::Wandering => Timer::from_seconds(30.0, TimerMode::Once),
|
||||
MonsterState::Hunting => Timer::from_seconds(15.0, TimerMode::Once),
|
||||
};
|
||||
|
||||
// reset pathfinding when state changes
|
||||
pathfinding.current_target = None;
|
||||
println!("new state={:?}", monster.state);
|
||||
}
|
||||
|
||||
// override state in some cases
|
||||
if let Some(player_pos) = player_pos {
|
||||
let distance = monster_transform.translation.distance(player_pos);
|
||||
|
||||
// if player is very close and monster isn't hunting, switch to hunting
|
||||
if distance < monster.detection_range * 0.5 && monster.state != MonsterState::Hunting {
|
||||
if rand.random_bool(0.75) { // 75% chance to notice player
|
||||
monster.state = MonsterState::Hunting;
|
||||
monster.state_timer = Timer::from_seconds(20.0, TimerMode::Once);
|
||||
danger.last_sound_timer.reset();
|
||||
pathfinding.current_target = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_monster(
|
||||
time: Res<Time>,
|
||||
mut monster_query: Query<(&Monster, &mut Transform, &mut Velocity, &mut MonsterPathfinding), Without<Player>>,
|
||||
player_query: Query<&Transform, With<Player>>,
|
||||
) {
|
||||
let mut rand = rand::rng();
|
||||
|
||||
if let Ok((monster, mut transform, mut velocity, mut pathfinding)) = monster_query.get_single_mut() {
|
||||
pathfinding.wander_target_timer.tick(time.delta());
|
||||
|
||||
// move based on state
|
||||
match monster.state {
|
||||
MonsterState::Dormant => {
|
||||
velocity.linvel = Vec3::ZERO;
|
||||
},
|
||||
MonsterState::Lurking => {
|
||||
// get a random wander target if we don't have one or timer expired
|
||||
if pathfinding.current_target.is_none() || pathfinding.wander_target_timer.just_finished() {
|
||||
let range = 30.0;
|
||||
let random_offset = Vec3::new(
|
||||
rand.random_range(-range..range),
|
||||
0.0,
|
||||
rand.random_range(-range..range)
|
||||
);
|
||||
|
||||
let base_pos = if let Ok(player_transform) = player_query.get_single() {
|
||||
player_transform.translation
|
||||
} else {
|
||||
transform.translation
|
||||
};
|
||||
|
||||
pathfinding.current_target = Some(base_pos + random_offset);
|
||||
pathfinding.wander_target_timer = Timer::from_seconds(10.0, TimerMode::Once);
|
||||
}
|
||||
|
||||
// move toward target at slow pace
|
||||
if let Some(target) = pathfinding.current_target {
|
||||
move_towards_target(&mut transform, &mut velocity, target, monster.speed * 0.65);
|
||||
}
|
||||
},
|
||||
MonsterState::Wandering => {
|
||||
if pathfinding.current_target.is_none() || pathfinding.wander_target_timer.just_finished() {
|
||||
if let Ok(player_transform) = player_query.get_single() {
|
||||
// choose a random position somewhat close to player
|
||||
let range = 15.0;
|
||||
let random_offset = Vec3::new(
|
||||
rand.random_range(-range..range),
|
||||
0.0,
|
||||
rand.random_range(-range..range)
|
||||
);
|
||||
|
||||
pathfinding.current_target = Some(player_transform.translation + random_offset);
|
||||
pathfinding.wander_target_timer = Timer::from_seconds(7.0, TimerMode::Once);
|
||||
}
|
||||
}
|
||||
|
||||
// move toward target at medium pace
|
||||
if let Some(target) = pathfinding.current_target {
|
||||
move_towards_target(&mut transform, &mut velocity, target, monster.speed * 0.85);
|
||||
}
|
||||
},
|
||||
MonsterState::Hunting => {
|
||||
// chase the motherfucker
|
||||
if let Ok(player_transform) = player_query.get_single() {
|
||||
move_towards_target(
|
||||
&mut transform,
|
||||
&mut velocity,
|
||||
player_transform.translation,
|
||||
monster.speed * 1.1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn move_towards_target(
|
||||
transform: &mut Transform,
|
||||
velocity: &mut Velocity,
|
||||
target: Vec3,
|
||||
speed: f32
|
||||
) {
|
||||
let direction = (target - transform.translation).normalize_or_zero();
|
||||
let direction = Vec3::new(direction.x, 0.0, direction.z).normalize_or_zero();
|
||||
velocity.linvel = direction * speed;
|
||||
|
||||
if direction.length_squared() > 0.01 {
|
||||
let look_direction = Vec3::new(direction.x, 0.0, direction.z).normalize_or_zero();
|
||||
if look_direction != Vec3::ZERO {
|
||||
let target_rotation = Quat::from_rotation_arc(Vec3::Z, look_direction);
|
||||
transform.rotation = transform.rotation.slerp(target_rotation, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn play_monster_sounds(
|
||||
time: Res<Time>,
|
||||
audio: Res<Audio>,
|
||||
audio_assets: Res<AudioAssets>,
|
||||
mut monster_query: Query<(&mut Monster, &Transform), Without<Player>>,
|
||||
player_query: Query<&Transform, With<Player>>,
|
||||
) {
|
||||
let mut rand = rand::rng();
|
||||
if let (Ok((mut monster, monster_transform)), Ok(player_transform)) =
|
||||
(monster_query.get_single_mut(), player_query.get_single()) {
|
||||
|
||||
monster.footstep_timer.tick(time.delta());
|
||||
let distance = monster_transform.translation.distance(player_transform.translation);
|
||||
|
||||
// play footstep sound if the timer finished
|
||||
if monster.footstep_timer.just_finished() {
|
||||
match monster.state {
|
||||
MonsterState::Hunting => {
|
||||
monster.footstep_timer = Timer::from_seconds(1.5, TimerMode::Once);
|
||||
},
|
||||
MonsterState::Lurking | MonsterState::Wandering => {
|
||||
monster.footstep_timer = Timer::from_seconds(
|
||||
rand.random_range(3.0..7.0),
|
||||
TimerMode::Once
|
||||
);
|
||||
},
|
||||
MonsterState::Dormant => {
|
||||
monster.footstep_timer = Timer::from_seconds(
|
||||
rand.random_range(10.0..20.0),
|
||||
TimerMode::Once
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
play_footstep_segment(audio, &audio_assets, distance, &monster.state, monster.footstep_timer.duration().as_secs_f32());
|
||||
|
||||
if monster.state == MonsterState::Hunting && rand.random_bool(0.3) {
|
||||
println!("Monster growl!");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn play_footstep_segment(
|
||||
audio: Res<Audio>,
|
||||
audio_assets: &Res<AudioAssets>,
|
||||
distance: f32,
|
||||
state: &MonsterState,
|
||||
dur: f32
|
||||
) {
|
||||
let base_volume: f32 = match state {
|
||||
MonsterState::Dormant => 0.6,
|
||||
MonsterState::Lurking => 0.65,
|
||||
MonsterState::Wandering => 0.8,
|
||||
MonsterState::Hunting => 1.0,
|
||||
};
|
||||
|
||||
// adjust volume based on distance (closer = louder)
|
||||
let distance_factor: f32 = 1.0 - (distance / 100.0).clamp(0.0, 1.0);
|
||||
let volume = base_volume * distance_factor;
|
||||
|
||||
// play only a short segment of the footstep sound
|
||||
// by only playing the last part
|
||||
let mut start_time = 30.0 - dur;
|
||||
// let mut rand = rand::rng();
|
||||
// start_time += rand.random_range(0.0..5.0);
|
||||
|
||||
audio.play(audio_assets.monster_footsteps.clone())
|
||||
.with_volume(Volume::Amplitude(volume as f64))
|
||||
.start_from((start_time as f64).min(27.0))
|
||||
.fade_in(AudioTween::linear(Duration::from_millis(100)))
|
||||
.handle();
|
||||
|
||||
println!("Monster footstep: State={:?}, Distance={:.2}, Volume={:.2}, Dur={:1}, Start={:1}",
|
||||
state, distance, volume, dur, (start_time as f64).min(27.0));
|
||||
}
|
||||
Reference in New Issue
Block a user