use bevy::ecs::message::{Message, MessageReader, MessageWriter};
use bevy::image::TextureAtlasLayout;
use bevy::prelude::*;
use bevy::sprite_render::MeshMaterial2d;
use bevy_smud::prelude::*;
use serde::Deserialize;
use std::fs;
use crate::app_state::overworld::character::components::PlayerControlled;
use crate::app_state::overworld::{OverworldEntity, OverworldState, OverworldUpdate};
use crate::config;
use crate::core::ui::PixelOutlineMaterial;
#[derive(Component, Debug, Default)]
pub struct ChaseHighlight;
#[derive(Component, Debug)]
pub struct ChaseEffect {
pub target_alpha: f32,
}
impl Default for ChaseEffect {
fn default() -> Self {
Self { target_alpha: 0.5 }
}
}
#[derive(Component)]
pub struct ChaseDarkOverlay;
#[derive(Component)]
pub struct ChasePlayerOutline {
pub current_size: Vec2,
}
#[derive(Component)]
pub struct ChaseHeartMarker;
#[derive(Component)]
pub struct ChaseEffectRoot;
#[derive(Resource, Default)]
pub struct ChaseTransition {
pub active: bool,
pub timer: f32,
pub transitioning_in: bool,
pub cleanup_done: bool,
}
const CHASE_CONFIG_PATH: &str = "overworld/chase_config.ron";
#[derive(Debug, Clone, Deserialize)]
pub struct HeartMarkerConfig {
pub texture_path: String,
pub offset: Vec2Config,
pub z_offset: f32,
#[serde(default = "default_heart_scale")]
pub scale: f32,
pub color: ColorConfig,
pub fade_duration: f32,
}
fn default_heart_scale() -> f32 {
0.5
}
impl Default for HeartMarkerConfig {
fn default() -> Self {
Self {
texture_path: "textures/common/ui/dr_heart.png".to_string(),
offset: Vec2Config { x: 0.0, y: -2.0 },
z_offset: 101.0,
scale: 0.5,
color: ColorConfig {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
fade_duration: 0.5,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct OutlineConfig {
pub color: ColorConfig,
pub fade_duration: f32,
}
impl Default for OutlineConfig {
fn default() -> Self {
Self {
color: ColorConfig {
r: 1.0,
g: 0.0,
b: 0.0,
a: 1.0,
},
fade_duration: 0.5,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct DarkOverlayConfig {
pub target_alpha: f32,
pub fade_duration: f32,
}
impl Default for DarkOverlayConfig {
fn default() -> Self {
Self {
target_alpha: 0.5,
fade_duration: 0.5,
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct Vec2Config {
#[serde(default)]
pub x: f32,
#[serde(default)]
pub y: f32,
}
impl Vec2Config {
pub fn to_vec2(&self) -> Vec2 {
Vec2::new(self.x, self.y)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ColorConfig {
pub r: f32,
pub g: f32,
pub b: f32,
#[serde(default = "default_alpha")]
pub a: f32,
}
fn default_alpha() -> f32 {
1.0
}
#[derive(Debug, Clone, Deserialize)]
pub enum HitboxShapeConfig {
Circle { radius: f32 },
Box { half_width: f32, half_height: f32 },
}
impl Default for HitboxShapeConfig {
fn default() -> Self {
HitboxShapeConfig::Circle { radius: 4.0 }
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct HitboxConfig {
pub shape: HitboxShapeConfig,
#[serde(default)]
pub offset: Vec2Config,
}
impl Default for HitboxConfig {
fn default() -> Self {
Self {
shape: HitboxShapeConfig::default(),
offset: Vec2Config { x: 0.0, y: -5.0 },
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct DamageUIConfig {
pub layout_path: String,
pub display_duration: f32,
}
impl Default for DamageUIConfig {
fn default() -> Self {
Self {
layout_path: "overworld/ui/damage_flash.ui_layout.ron".to_string(),
display_duration: 0.5,
}
}
}
#[derive(Debug, Clone, Deserialize, Resource)]
pub struct ChaseConfig {
pub heart_marker: HeartMarkerConfig,
pub outline: OutlineConfig,
pub dark_overlay: DarkOverlayConfig,
#[serde(default)]
pub hitbox: HitboxConfig,
#[serde(default)]
pub damage_ui: DamageUIConfig,
}
impl Default for ChaseConfig {
fn default() -> Self {
Self {
heart_marker: HeartMarkerConfig::default(),
outline: OutlineConfig::default(),
dark_overlay: DarkOverlayConfig::default(),
hitbox: HitboxConfig::default(),
damage_ui: DamageUIConfig::default(),
}
}
}
impl ChaseConfig {
pub fn load() -> Self {
if let Some(path) = config::resolve_path(CHASE_CONFIG_PATH) {
if let Ok(contents) = fs::read_to_string(&path) {
if let Ok(config) = ron::de::from_str(&contents) {
info!("Chase: Loaded config from {}", path.display());
return config;
} else {
warn!(
"Chase: Failed to parse config at {}, using defaults",
path.display()
);
}
}
}
info!("Chase: Using default config");
Self::default()
}
pub fn transition_duration(&self) -> f32 {
self.heart_marker.fade_duration
}
}
pub struct ChasePlugin;
impl Plugin for ChasePlugin {
fn build(&self, app: &mut App) {
let chase_config = ChaseConfig::load();
app.insert_resource(chase_config)
.init_resource::<ChaseTransition>()
.init_resource::<DamageUIState>()
.init_resource::<PlayerInvincibility>()
.add_message::<ChasePlayerDamageEvent>()
.add_systems(
OnEnter(OverworldState::Chase),
(
setup_chase_effect_root_system,
start_chase_transition_in_system,
setup_chase_hud_system,
),
)
.add_systems(
OnExit(OverworldState::Chase),
(start_chase_transition_out_system, cleanup_chase_hud_system),
)
.add_systems(
Update,
(
update_chase_transition_system,
spawn_chase_dark_overlay_system,
spawn_player_outline_system,
spawn_heart_marker_system,
spawn_player_hitbox_system,
update_player_outline_system,
update_chase_effect_alpha_system,
update_heart_marker_alpha_system,
chase_damage_detection_system,
update_player_invincibility_system,
damage_ui_display_system,
cleanup_chase_effects_system,
cleanup_player_hitbox_system,
)
.chain()
.in_set(OverworldUpdate),
);
}
}
fn setup_chase_effect_root_system(mut commands: Commands) {
commands.spawn((
OverworldEntity(),
ChaseEffectRoot,
Name::new("ChaseEffectRoot"),
Transform::default(),
Visibility::default(),
));
info!("Chase: Created effect root entity");
}
fn start_chase_transition_in_system(mut transition: ResMut<ChaseTransition>) {
transition.active = true;
transition.timer = 0.0;
transition.transitioning_in = true;
transition.cleanup_done = false;
info!("Chase: Starting transition IN");
}
fn start_chase_transition_out_system(
mut transition: ResMut<ChaseTransition>,
chase_config: Res<ChaseConfig>,
) {
transition.transitioning_in = false;
transition.timer = chase_config.transition_duration();
info!("Chase: Starting transition OUT");
}
fn update_chase_transition_system(
time: Res<Time>,
mut transition: ResMut<ChaseTransition>,
chase_config: Res<ChaseConfig>,
) {
if !transition.active && !transition.transitioning_in && transition.timer <= 0.0 {
return;
}
let duration = chase_config.transition_duration();
if transition.transitioning_in {
transition.timer = (transition.timer + time.delta_secs()).min(duration);
} else {
transition.timer = (transition.timer - time.delta_secs()).max(0.0);
if transition.timer <= 0.0 {
transition.active = false;
}
}
}
fn spawn_chase_dark_overlay_system(
mut commands: Commands,
mut shaders: ResMut<Assets<Shader>>,
transition: Res<ChaseTransition>,
chase_config: Res<ChaseConfig>,
chase_root: Query<Entity, With<ChaseEffectRoot>>,
existing_overlay: Query<Entity, With<ChaseDarkOverlay>>,
) {
if !transition.transitioning_in || !existing_overlay.is_empty() {
return;
}
let Ok(root_entity) = chase_root.single() else {
return;
};
let overlay_size = 10000.0;
let sdf = shaders.add_sdf_expr(format!(
"smud::sd_box(p, vec2<f32>({}, {}))",
overlay_size / 2.0,
overlay_size / 2.0
));
commands.entity(root_entity).with_children(|parent| {
parent.spawn((
ChaseDarkOverlay,
ChaseEffect {
target_alpha: chase_config.dark_overlay.target_alpha,
},
SmudShape {
color: Color::srgba(0.0, 0.0, 0.0, 0.0),
sdf,
frame: Frame::Quad(overlay_size),
fill: SIMPLE_FILL_HANDLE,
..default()
},
Transform::from_xyz(0.0, 0.0, 50.0), Name::new("ChaseDarkOverlay"),
));
});
info!("Chase: Spawned dark overlay");
}
#[allow(clippy::type_complexity)]
fn spawn_player_outline_system(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<PixelOutlineMaterial>>,
images: Res<Assets<Image>>,
atlas_layouts: Res<Assets<TextureAtlasLayout>>,
transition: Res<ChaseTransition>,
chase_root: Query<Entity, With<ChaseEffectRoot>>,
player_query: Query<(Entity, &Sprite, &Transform), With<PlayerControlled>>,
existing_outline: Query<Entity, With<ChasePlayerOutline>>,
) {
if !transition.transitioning_in || !existing_outline.is_empty() {
return;
}
let Ok(root_entity) = chase_root.single() else {
return;
};
let Ok((_player_entity, sprite, player_transform)) = player_query.single() else {
return;
};
let texture = sprite.image.clone();
let Some(image) = images.get(&texture) else {
return;
};
let tex_size = image.size().as_vec2();
let (uv_rect, sprite_size) = if let Some(ref atlas) = sprite.texture_atlas {
if let Some(layout) = atlas_layouts.get(&atlas.layout) {
let rect = layout.textures[atlas.index];
let uv = Vec4::new(
rect.min.x as f32 / tex_size.x,
rect.min.y as f32 / tex_size.y,
rect.max.x as f32 / tex_size.x,
rect.max.y as f32 / tex_size.y,
);
let size = Vec2::new(rect.width() as f32, rect.height() as f32);
(uv, size)
} else {
(Vec4::new(0.0, 0.0, 1.0, 1.0), tex_size)
}
} else if let Some(rect) = sprite.rect {
let uv = Vec4::new(
rect.min.x / tex_size.x,
rect.min.y / tex_size.y,
rect.max.x / tex_size.x,
rect.max.y / tex_size.y,
);
let size = Vec2::new(rect.width(), rect.height());
(uv, size)
} else {
(Vec4::new(0.0, 0.0, 1.0, 1.0), tex_size)
};
let flip = Vec4::new(
if sprite.flip_x { 1.0 } else { 0.0 },
if sprite.flip_y { 1.0 } else { 0.0 },
0.0,
0.0,
);
let outline_size = sprite_size + Vec2::splat(2.0);
let mesh = meshes.add(Rectangle::new(outline_size.x, outline_size.y));
let material = materials.add(PixelOutlineMaterial {
params: LinearRgba::new(1.0, 0.0, 0.0, 0.0), uv_rect,
flip,
texture,
});
commands.entity(root_entity).with_children(|parent| {
parent.spawn((
ChasePlayerOutline {
current_size: sprite_size,
},
ChaseEffect { target_alpha: 1.0 },
Mesh2d(mesh),
MeshMaterial2d(material),
Transform::from_translation(player_transform.translation + Vec3::new(0.0, 0.0, 100.0)),
Name::new("ChasePlayerOutline"),
));
});
info!("Chase: Spawned player outline");
}
fn spawn_heart_marker_system(
mut commands: Commands,
asset_server: Res<AssetServer>,
transition: Res<ChaseTransition>,
chase_config: Res<ChaseConfig>,
player_query: Query<Entity, With<PlayerControlled>>,
existing_markers: Query<Entity, With<ChaseHeartMarker>>,
) {
if !transition.transitioning_in || !existing_markers.is_empty() {
return;
}
let Ok(player_entity) = player_query.single() else {
return;
};
let config = &chase_config.heart_marker;
let offset = config.offset.to_vec2();
let texture: Handle<Image> = asset_server.load(&config.texture_path);
commands.entity(player_entity).with_children(|parent| {
parent.spawn((
ChaseHeartMarker,
Sprite {
image: texture,
color: Color::srgba(config.color.r, config.color.g, config.color.b, 0.0),
..default()
},
Transform::from_xyz(offset.x, offset.y, config.z_offset)
.with_scale(Vec3::splat(config.scale)),
Name::new("ChaseHeartMarker"),
));
});
info!("Chase: Spawned heart marker");
}
#[allow(clippy::type_complexity)]
fn update_player_outline_system(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut outline_query: Query<
(
Entity,
&mut Transform,
&mut ChasePlayerOutline,
&MeshMaterial2d<PixelOutlineMaterial>,
),
Without<PlayerControlled>,
>,
player_query: Query<(&Transform, &Sprite), With<PlayerControlled>>,
images: Res<Assets<Image>>,
atlas_layouts: Res<Assets<TextureAtlasLayout>>,
mut materials: ResMut<Assets<PixelOutlineMaterial>>,
transition: Res<ChaseTransition>,
) {
if !transition.active {
return;
}
let Ok((player_transform, sprite)) = player_query.single() else {
return;
};
for (entity, mut outline_transform, mut outline_marker, material_handle) in
outline_query.iter_mut()
{
outline_transform.translation.x = player_transform.translation.x;
outline_transform.translation.y = player_transform.translation.y;
if let Some(material) = materials.get_mut(&material_handle.0) {
material.texture = sprite.image.clone();
material.flip = Vec4::new(
if sprite.flip_x { 1.0 } else { 0.0 },
if sprite.flip_y { 1.0 } else { 0.0 },
0.0,
0.0,
);
if let Some(image) = images.get(&sprite.image) {
let tex_size = image.size().as_vec2();
let (new_uv_rect, sprite_size) = if let Some(ref atlas) = sprite.texture_atlas {
if let Some(layout) = atlas_layouts.get(&atlas.layout) {
let rect = layout.textures[atlas.index];
let uv = Vec4::new(
rect.min.x as f32 / tex_size.x,
rect.min.y as f32 / tex_size.y,
rect.max.x as f32 / tex_size.x,
rect.max.y as f32 / tex_size.y,
);
let size = Vec2::new(rect.width() as f32, rect.height() as f32);
(uv, size)
} else {
(Vec4::new(0.0, 0.0, 1.0, 1.0), tex_size)
}
} else if let Some(rect) = sprite.rect {
let uv = Vec4::new(
rect.min.x / tex_size.x,
rect.min.y / tex_size.y,
rect.max.x / tex_size.x,
rect.max.y / tex_size.y,
);
let size = Vec2::new(rect.width(), rect.height());
(uv, size)
} else {
(Vec4::new(0.0, 0.0, 1.0, 1.0), tex_size)
};
material.uv_rect = new_uv_rect;
if (outline_marker.current_size - sprite_size).length() > 0.01 {
outline_marker.current_size = sprite_size;
let outline_size = sprite_size + Vec2::splat(2.0);
let new_mesh = meshes.add(Rectangle::new(outline_size.x, outline_size.y));
commands.entity(entity).insert(Mesh2d(new_mesh));
}
}
}
}
}
fn update_chase_effect_alpha_system(
transition: Res<ChaseTransition>,
chase_config: Res<ChaseConfig>,
mut smud_effects: Query<(&mut SmudShape, &ChaseEffect)>,
mut outline_query: Query<&MeshMaterial2d<PixelOutlineMaterial>, With<ChasePlayerOutline>>,
mut materials: ResMut<Assets<PixelOutlineMaterial>>,
) {
let duration = chase_config.transition_duration();
let progress = if duration > 0.0 {
transition.timer / duration
} else {
1.0
};
for (mut shape, effect) in smud_effects.iter_mut() {
let target_alpha = effect.target_alpha * progress;
let mut color = shape.color.to_srgba();
color.alpha = target_alpha;
shape.color = color.into();
}
for material_handle in outline_query.iter_mut() {
if let Some(material) = materials.get_mut(&material_handle.0) {
material.params.alpha = progress;
}
}
}
fn update_heart_marker_alpha_system(
transition: Res<ChaseTransition>,
chase_config: Res<ChaseConfig>,
player_invincibility: Res<PlayerInvincibility>,
mut heart_markers: Query<&mut Sprite, With<ChaseHeartMarker>>,
) {
if player_invincibility.is_invincible() {
return;
}
if !transition.active {
return;
}
let duration = chase_config.transition_duration();
let progress = if duration > 0.0 {
transition.timer / duration
} else {
1.0
};
let config = &chase_config.heart_marker;
for mut sprite in heart_markers.iter_mut() {
sprite.color = Color::srgba(
config.color.r,
config.color.g,
config.color.b,
config.color.a * progress,
);
}
}
fn cleanup_chase_effects_system(
mut commands: Commands,
mut transition: ResMut<ChaseTransition>,
chase_root: Query<Entity, With<ChaseEffectRoot>>,
overlays: Query<Entity, With<ChaseDarkOverlay>>,
outlines: Query<Entity, With<ChasePlayerOutline>>,
heart_markers: Query<Entity, With<ChaseHeartMarker>>,
) {
if transition.active || transition.transitioning_in || transition.timer > 0.0 {
return;
}
if transition.cleanup_done {
return;
}
for entity in overlays.iter() {
commands.entity(entity).despawn();
}
for entity in outlines.iter() {
commands.entity(entity).despawn();
}
for entity in heart_markers.iter() {
commands.entity(entity).despawn();
}
if let Ok(root) = chase_root.single() {
commands.entity(root).despawn();
}
transition.cleanup_done = true;
info!("Chase: Cleaned up all effects");
}
#[derive(Component)]
pub struct ChasePlayerHitbox;
#[derive(Resource, Default)]
pub struct DamageUIState {
pub showing: bool,
pub timer: f32,
}
#[derive(Resource, Default)]
pub struct PlayerInvincibility {
pub active: bool,
pub timer: f32,
pub flash_timer: f32,
pub flash_state: bool,
}
impl PlayerInvincibility {
pub fn start(&mut self, duration: f32) {
self.active = true;
self.timer = duration;
self.flash_timer = 0.0;
self.flash_state = true;
}
pub fn is_invincible(&self) -> bool {
self.active && self.timer > 0.0
}
}
#[derive(Clone)]
pub struct ChasePlayerDamageEvent {
pub damage: f32,
}
impl Message for ChasePlayerDamageEvent {}
pub fn spawn_player_hitbox_system(
mut commands: Commands,
chase_config: Res<ChaseConfig>,
transition: Res<ChaseTransition>,
player_query: Query<Entity, (With<PlayerControlled>, Without<ChasePlayerHitbox>)>,
) {
if !transition.transitioning_in {
return;
}
let Ok(player_entity) = player_query.single() else {
return;
};
let trigger_collider = match &chase_config.hitbox.shape {
HitboxShapeConfig::Circle { radius } => {
crate::core::collision::TriggerCollider::Circle { radius: *radius }
}
HitboxShapeConfig::Box {
half_width,
half_height,
} => crate::core::collision::TriggerCollider::Box {
half_size: Vec2::new(*half_width, *half_height),
},
};
commands.entity(player_entity).insert((
ChasePlayerHitbox,
trigger_collider,
crate::core::collision::HitboxOffset(chase_config.hitbox.offset.to_vec2()),
));
info!(
"Chase: Added player hitbox with shape {:?}",
chase_config.hitbox.shape
);
}
pub fn cleanup_player_hitbox_system(
mut commands: Commands,
transition: Res<ChaseTransition>,
player_query: Query<Entity, With<ChasePlayerHitbox>>,
) {
if transition.active || transition.transitioning_in || transition.timer > 0.0 {
return;
}
for player_entity in player_query.iter() {
commands
.entity(player_entity)
.remove::<ChasePlayerHitbox>()
.remove::<crate::core::collision::TriggerCollider>()
.remove::<crate::core::collision::HitboxOffset>();
info!("Chase: Removed player hitbox");
}
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::type_complexity)]
pub fn chase_damage_detection_system(
mut commands: Commands,
time: Res<Time>,
chase_config: Res<ChaseConfig>,
player_behavior: Res<crate::app_state::overworld::player::config::PlayerBehavior>,
overworld_state: Res<State<OverworldState>>,
asset_server: Res<AssetServer>,
mut player_invincibility: ResMut<PlayerInvincibility>,
mut player_data: ResMut<crate::core::data::PlayerData>,
#[cfg(all(feature = "bevy_kira_audio", not(feature = "firewheel")))] audio: Res<
bevy_kira_audio::Audio,
>,
player_query: Query<
(
&Transform,
&crate::core::collision::TriggerCollider,
Option<&crate::core::collision::HitboxOffset>,
),
With<ChasePlayerHitbox>,
>,
mut bullet_query: Query<
(
Entity,
&GlobalTransform,
&crate::core::collision::TriggerCollider,
&crate::core::danmaku::BulletDamage,
&crate::core::danmaku::BulletHitBehavior,
&mut crate::core::danmaku::BulletLastHitTime,
&crate::core::danmaku::BulletMotionState,
),
With<crate::core::danmaku::Bullet>,
>,
mut damage_events: MessageWriter<ChasePlayerDamageEvent>,
mut last_player_state: Local<Option<(Vec2, f64)>>,
) {
if *overworld_state.get() != OverworldState::Chase {
*last_player_state = None; return;
}
let Ok((player_transform, player_hitbox, hitbox_offset)) = player_query.single() else {
return;
};
let player_center = player_transform.translation.truncate()
+ hitbox_offset
.map(|o| o.0)
.unwrap_or(chase_config.hitbox.offset.to_vec2());
let current_time = time.elapsed_secs_f64();
let player_is_moving = if let Some((last_pos, last_time)) = *last_player_state {
if current_time - last_time > time.delta_secs_f64() * 1.5 {
false
} else {
player_center.distance_squared(last_pos) > 0.0001 }
} else {
false
};
*last_player_state = Some((player_center, current_time));
let is_invincible = player_invincibility.is_invincible();
for (
bullet_entity,
bullet_transform,
bullet_collider,
bullet_damage,
hit_behavior,
mut last_hit_time,
motion_state,
) in bullet_query.iter_mut()
{
let bullet_center = bullet_transform.translation().truncate();
if !check_trigger_collision(player_hitbox, player_center, bullet_collider, bullet_center) {
continue;
}
if hit_behavior.invincibility_duration > 0.0 {
let time_since_last_hit = motion_state.elapsed - last_hit_time.0;
if time_since_last_hit < hit_behavior.invincibility_duration {
continue;
}
}
let should_damage = if hit_behavior.damage_on_player_moving {
player_is_moving
} else if hit_behavior.damage_on_player_stationary {
!player_is_moving
} else {
true
};
if is_invincible {
if hit_behavior.despawn_on_hit && should_damage {
commands
.entity(bullet_entity)
.insert(crate::core::danmaku::DespawnBullet);
}
continue;
}
if should_damage {
last_hit_time.0 = motion_state.elapsed;
let damage = bullet_damage.0 as usize;
if player_data.hp > damage {
player_data.hp -= damage;
} else {
player_data.hp = 0;
}
damage_events.write(ChasePlayerDamageEvent {
damage: bullet_damage.0,
});
player_invincibility.start(player_behavior.invincibility.duration);
#[cfg(all(feature = "bevy_kira_audio", not(feature = "firewheel")))]
crate::core::audio::play_sound(&audio, &asset_server, "hurtsound.wav");
#[cfg(feature = "firewheel")]
crate::core::audio::play_sound(&mut commands, &asset_server, "hurtsound.wav");
info!(
"Chase: Player hit! Damage: {}, HP: {}/{}",
damage, player_data.hp, player_data.hp_max
);
if hit_behavior.despawn_on_hit {
commands
.entity(bullet_entity)
.insert(crate::core::danmaku::DespawnBullet);
}
break;
}
}
}
fn check_trigger_collision(
a: &crate::core::collision::TriggerCollider,
a_center: Vec2,
b: &crate::core::collision::TriggerCollider,
b_center: Vec2,
) -> bool {
use crate::core::collision::TriggerCollider;
match (a, b) {
(TriggerCollider::Circle { radius: r1 }, TriggerCollider::Circle { radius: r2 }) => {
let dist = a_center.distance(b_center);
dist <= r1 + r2
}
(TriggerCollider::Box { half_size: hs1 }, TriggerCollider::Box { half_size: hs2 }) => {
let diff = (a_center - b_center).abs();
diff.x <= hs1.x + hs2.x && diff.y <= hs1.y + hs2.y
}
(TriggerCollider::Circle { radius }, TriggerCollider::Box { half_size })
| (TriggerCollider::Box { half_size }, TriggerCollider::Circle { radius }) => {
let (circle_center, box_center, box_half) =
if matches!(a, TriggerCollider::Circle { .. }) {
(a_center, b_center, *half_size)
} else {
(b_center, a_center, *half_size)
};
let closest = Vec2::new(
circle_center
.x
.clamp(box_center.x - box_half.x, box_center.x + box_half.x),
circle_center
.y
.clamp(box_center.y - box_half.y, box_center.y + box_half.y),
);
circle_center.distance(closest) <= *radius
}
}
}
pub fn update_player_invincibility_system(
time: Res<Time>,
chase_config: Res<ChaseConfig>,
player_behavior: Res<crate::app_state::overworld::player::config::PlayerBehavior>,
overworld_state: Res<State<OverworldState>>,
mut player_invincibility: ResMut<PlayerInvincibility>,
mut heart_markers: Query<&mut Sprite, With<ChaseHeartMarker>>,
) {
if *overworld_state.get() != OverworldState::Chase {
return;
}
if !player_invincibility.active {
return;
}
let delta = time.delta_secs();
let config = &player_behavior.invincibility;
player_invincibility.timer -= delta;
if player_invincibility.timer <= 0.0 {
player_invincibility.active = false;
player_invincibility.timer = 0.0;
player_invincibility.flash_state = true;
for mut sprite in heart_markers.iter_mut() {
let heart_config = &chase_config.heart_marker;
sprite.color = Color::srgba(
heart_config.color.r,
heart_config.color.g,
heart_config.color.b,
heart_config.color.a,
);
}
info!("Chase: Player invincibility ended");
return;
}
player_invincibility.flash_timer += delta;
if player_invincibility.flash_timer >= config.flash_interval {
player_invincibility.flash_timer = 0.0;
player_invincibility.flash_state = !player_invincibility.flash_state;
let color = if player_invincibility.flash_state {
parse_hex_color_for_heart(&config.normal_color)
} else {
parse_hex_color_for_heart(&config.flash_color)
};
for mut sprite in heart_markers.iter_mut() {
if let Some(c) = color {
sprite.color = c;
}
}
}
}
fn parse_hex_color_for_heart(hex: &str) -> Option<Color> {
let hex = hex.trim_start_matches('#');
match hex.len() {
6 => {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::srgb_u8(r, g, b))
}
_ => None,
}
}
pub fn damage_ui_display_system(
mut commands: Commands,
time: Res<Time>,
mut damage_ui_state: ResMut<DamageUIState>,
mut damage_events: MessageReader<ChasePlayerDamageEvent>,
damage_ui_query: Query<Entity, With<DamageUIMarker>>,
camera_query: Query<&Transform, With<Camera2d>>,
) {
for _event in damage_events.read() {
if !damage_ui_state.showing {
let camera_pos = camera_query
.single()
.map(|t| t.translation)
.unwrap_or(Vec3::ZERO);
commands.spawn((
Sprite {
color: Color::srgba(1.0, 0.0, 0.0, 0.3),
custom_size: Some(Vec2::new(640.0, 480.0)),
..default()
},
Transform::from_translation(Vec3::new(camera_pos.x, camera_pos.y, 500.0)),
OverworldEntity(),
DamageUIMarker,
Name::new("DamageFlashOverlay"),
));
damage_ui_state.showing = true;
damage_ui_state.timer = 0.1; info!("Chase: Showing damage flash overlay");
} else {
damage_ui_state.timer = 0.1;
}
}
if damage_ui_state.showing {
damage_ui_state.timer -= time.delta_secs();
if damage_ui_state.timer <= 0.0 {
for entity in damage_ui_query.iter() {
commands.entity(entity).despawn();
}
damage_ui_state.showing = false;
info!("Chase: Hiding damage flash overlay");
}
}
}
#[derive(Component)]
pub struct DamageUIMarker;
#[derive(Component)]
pub struct ChaseHUDRoot;
fn setup_chase_hud_system(mut commands: Commands, asset_server: Res<AssetServer>) {
info!("Chase: Setting up Chase HUD");
let handle = asset_server.load("overworld/ui/damage_flash.ui_layout.ron");
commands.insert_resource(crate::core::ui::UILayoutHandle {
handle,
last_modified: None,
});
commands.spawn((
crate::core::ui::components::RonUI::new(
crate::core::ui::components::UILayer::new("ChaseHUD"),
0,
),
Transform::default(),
GlobalTransform::default(),
Visibility::default(),
InheritedVisibility::default(),
ViewVisibility::default(),
OverworldEntity(),
ChaseHUDRoot,
Name::new("ChaseHUD Root"),
));
commands.insert_resource(crate::core::ui::UILayoutWatcher::new());
info!("Chase: Chase HUD setup complete");
}
fn cleanup_chase_hud_system(
mut commands: Commands,
chase_hud_query: Query<Entity, With<ChaseHUDRoot>>,
ron_driven_ui_query: Query<Entity, With<crate::core::ui::RonDrivenUI>>,
) {
info!("Chase: Cleaning up Chase HUD");
for entity in chase_hud_query.iter() {
commands.entity(entity).despawn();
}
for entity in ron_driven_ui_query.iter() {
commands.entity(entity).despawn();
}
commands.remove_resource::<crate::core::ui::UILayoutHandle>();
commands.remove_resource::<crate::core::ui::UILayoutWatcher>();
info!("Chase: Chase HUD cleanup complete");
}