diff --git a/assets/bob/rigging/Bob-Chest.png b/assets/bob/rigging/Bob-Chest.png new file mode 100644 index 0000000..83fd3f7 Binary files /dev/null and b/assets/bob/rigging/Bob-Chest.png differ diff --git a/assets/bob/rigging/Bob-Head.png b/assets/bob/rigging/Bob-Head.png new file mode 100644 index 0000000..9b97209 Binary files /dev/null and b/assets/bob/rigging/Bob-Head.png differ diff --git a/assets/bob/rigging/Bob-Left-Arm.png b/assets/bob/rigging/Bob-Left-Arm.png new file mode 100644 index 0000000..1cbb3f2 Binary files /dev/null and b/assets/bob/rigging/Bob-Left-Arm.png differ diff --git a/assets/bob/rigging/Bob-Left-Leg.png b/assets/bob/rigging/Bob-Left-Leg.png new file mode 100644 index 0000000..eaf9806 Binary files /dev/null and b/assets/bob/rigging/Bob-Left-Leg.png differ diff --git a/assets/bob/rigging/Bob-Right-Arm.png b/assets/bob/rigging/Bob-Right-Arm.png new file mode 100644 index 0000000..ff2d1bc Binary files /dev/null and b/assets/bob/rigging/Bob-Right-Arm.png differ diff --git a/assets/bob/rigging/Bob-Right-Leg.png b/assets/bob/rigging/Bob-Right-Leg.png new file mode 100644 index 0000000..4ad6c17 Binary files /dev/null and b/assets/bob/rigging/Bob-Right-Leg.png differ diff --git a/assets/bob/rigging/Bob-Weapon.png b/assets/bob/rigging/Bob-Weapon.png new file mode 100644 index 0000000..39b3eee Binary files /dev/null and b/assets/bob/rigging/Bob-Weapon.png differ diff --git a/src/game/components/mod.rs b/src/game/components/mod.rs index eddf182..7fec202 100644 --- a/src/game/components/mod.rs +++ b/src/game/components/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod moving; pub(crate) mod npc; pub(crate) mod plasma; pub(crate) mod player; +pub(crate) mod ragdoll; #[derive(Component)] pub(crate) struct SpawnedLevelEntity; diff --git a/src/game/components/ragdoll.rs b/src/game/components/ragdoll.rs new file mode 100644 index 0000000..ae8dc62 --- /dev/null +++ b/src/game/components/ragdoll.rs @@ -0,0 +1,37 @@ +use bevy::prelude::*; + +/// Marker: kennzeichnet die Chest-Entity des Ragdolls. +/// Physik (Velocity, Gravity, Collision) wird von avian2d gesteuert. +#[derive(Component, Debug, Clone, Copy)] +pub(crate) struct RagdollChest; + +/// Marker: kennzeichnet die fliegende Waffe des Ragdolls. +/// Physik wird von avian2d gesteuert (Dynamic RigidBody). +#[derive(Component, Debug, Clone, Copy)] +pub(crate) struct RagdollWeapon; + +/// Eine einzelne Gliedmaße. +/// Wenn `chest_entity` Some ist, wird die Position jedes Frame hart an den +/// Joint-Punkt des Chests geknüpft (Position Constraint). Nur die Rotation +/// ist frei. +#[derive(Component, Debug, Clone)] +pub(crate) struct RagdollLimb { + pub(crate) angular_velocity: f32, + /// Entity des Chests, an dem dieses Glied hängt. + pub(crate) chest_entity: Option, + /// Offset des Joint-Ankerpunkts vom Chest-Zentrum (Chest-Local-Space, y-up, skaliert). + pub(crate) chest_joint_local: Vec2, + /// Offset des Pivot-Punkts von diesem Glied-Zentrum (Limb-Local-Space, y-up, skaliert). + pub(crate) limb_pivot_local: Vec2, +} + +/// Marker: Bobs Sprite ist versteckt weil ein Ragdoll aktiv ist. +#[derive(Component, Debug, Clone, Copy)] +pub(crate) struct RagdollActive; + +/// Wird einmalig gefeuert wenn Bobs HP auf 0 fällt. +#[derive(Event, Debug, Clone, Copy)] +pub(crate) struct PlayerDiedEvent { + pub(crate) player_position: Vec2, + pub(crate) killer_position: Option, +} diff --git a/src/game/game_view.rs b/src/game/game_view.rs index 1b3f502..c1a60f6 100644 --- a/src/game/game_view.rs +++ b/src/game/game_view.rs @@ -17,6 +17,8 @@ mod animation; mod npc; #[path = "systems/player.rs"] mod player; +#[path = "systems/ragdoll.rs"] +mod ragdoll; #[path = "systems/setup.rs"] mod setup; @@ -85,16 +87,18 @@ impl ActiveLevelBounds { impl Plugin for GameViewPlugin { fn build(&self, app: &mut App) { - app.add_systems( + app.add_event::() + .add_systems( OnEnter(crate::AppState::GameView), ( setup::setup_game_view, camera::snap_camera_to_player, player::configure_player_controller, - hud::spawn_player_health_hud, + hud::spawn_player_health_hud, // ← aus main ) .chain(), ) + // --- Block 1: Input / Physik / Kampf --- .add_systems( Update, ( @@ -107,6 +111,8 @@ impl Plugin for GameViewPlugin { npc::control_moving_entities, combat::tick_invincibility_timers, combat::apply_hostile_contact_damage, + ragdoll::spawn_ragdoll.after(combat::apply_hostile_contact_damage), + ragdoll::update_ragdoll_parts, combat::shoot_plasma.before(combat::update_plasma_beams), ( combat::update_plasma_beams, @@ -116,22 +122,27 @@ impl Plugin for GameViewPlugin { .chain() .before(animation::tick_hit_state_timers) .before(animation::apply_state_animation), - ( - animation::sync_death_state_from_health, - combat::disable_dead_npc_collisions, - combat::play_hostile_death_quotes, - animation::tick_hit_state_timers, - animation::apply_state_animation, - combat::despawn_dead_entities, - ), + ) + .run_if(in_state(crate::AppState::GameView)), + ) + // --- Block 2: Animation / UI / Debug / Zustandsübergänge --- + .add_systems( + Update, + ( + animation::sync_death_state_from_health, + combat::disable_dead_npc_collisions, // ← aus main + combat::play_hostile_death_quotes, + animation::tick_hit_state_timers, + animation::apply_state_animation, + combat::despawn_dead_entities, debug::toggle_hitbox_debug_lines, debug::update_debug_stats_labels, debug::toggle_debug_overlay, debug::draw_hitbox_debug_lines, - hud::update_player_health_hud, + hud::update_player_health_hud, // ← aus main ( - combat::detect_player_defeated, - combat::detect_player_reached_exit, + combat::detect_player_defeated, // ← aus main + combat::detect_player_reached_exit, // ← aus main combat::return_to_main_menu, ), ) @@ -145,4 +156,3 @@ impl Plugin for GameViewPlugin { } } - diff --git a/src/game/systems/combat.rs b/src/game/systems/combat.rs index 39c96a7..351164c 100644 --- a/src/game/systems/combat.rs +++ b/src/game/systems/combat.rs @@ -21,6 +21,7 @@ use crate::game::components::plasma::{ PLASMA_IMPACT_LIFETIME_SECS, PLASMA_IMPACT_MAX_SPEED, PLASMA_IMPACT_MIN_SPEED, PLASMA_IMPACT_PARTICLE_COUNT, PLASMA_ORIGIN_HEIGHT_RATIO_FROM_BOTTOM, PLASMA_Z, }; +use crate::game::components::ragdoll::{PlayerDiedEvent, RagdollActive}; use crate::game::components::SpawnedLevelEntity; use crate::audio_settings::AudioSettings; use crate::AppState; @@ -64,6 +65,7 @@ pub(super) fn tick_invincibility_timers( pub(super) fn apply_hostile_contact_damage( mut commands: Commands, hostile_query: Query<(&Damage, Option<&Health>), (With, Without)>, + hostile_transforms: Query<&Transform, (With, Without)>, mut hostile_states: Query< (&mut AnimationState, Option<&HitStateTimer>), (With, Without), @@ -74,11 +76,13 @@ pub(super) fn apply_hostile_contact_damage( &avian2d::prelude::CollidingEntities, &mut Health, &mut AnimationState, + &Transform, ), - (With, Without, Without), + (With, Without, Without, Without), >, + mut player_died: EventWriter, ) { - for (player_entity, colliding_entities, mut health, mut player_state) in &mut player_query { + for (player_entity, colliding_entities, mut health, mut player_state, player_transform) in &mut player_query { if health.is_dead() { continue; } @@ -99,6 +103,20 @@ pub(super) fn apply_hostile_contact_damage( commands .entity(player_entity) .insert(HitStateTimer::new(HIT_STATE_SECONDS, player_state.version)); + } else { + // Bob just died — fire ragdoll event. + let killer_pos = hostile_transforms + .get(colliding_entity) + .ok() + .map(|t| t.translation.xy()); + + player_died.send(PlayerDiedEvent { + player_position: player_transform.translation.xy(), + killer_position: killer_pos, + }); + + // Prevent duplicate events in subsequent frames. + commands.entity(player_entity).insert(RagdollActive); } if let Ok((mut hostile_state, hostile_hit_timer)) = hostile_states.get_mut(colliding_entity) diff --git a/src/game/systems/ragdoll.rs b/src/game/systems/ragdoll.rs new file mode 100644 index 0000000..3e5acdf --- /dev/null +++ b/src/game/systems/ragdoll.rs @@ -0,0 +1,311 @@ +use std::collections::HashMap; + +use avian2d::prelude::{AngularVelocity, Collider, LinearVelocity, RigidBody}; +use bevy::prelude::*; + +use crate::game::components::player::Player; +use crate::game::components::ragdoll::{PlayerDiedEvent, RagdollChest, RagdollLimb, RagdollWeapon}; + +use super::GameViewEntity; + +const RAGDOLL_Z: f32 = 5.0; +/// Maximale Winkelabweichung eines Gelenks vom Chest-Winkel (±75°). +const MAX_JOINT_ANGLE_RAD: f32 = 75.0_f32 * std::f32::consts::PI / 180.0; + +// --------------------------------------------------------------------------- +// Render-Skalierung der Rigging-PNGs (hier einstellen um Größe anzupassen) +// --------------------------------------------------------------------------- +const S: f32 = 0.23; + +const CHEST_SIZE: Vec2 = Vec2::new(250.0 * S, 250.0 * S); +/// Collision-Box etwas kleiner als das Sprite (ca. 60 %). +const CHEST_COLLIDER: Vec2 = Vec2::new(250.0 * S * 0.6, 250.0 * S * 0.6); +const HEAD_SIZE: Vec2 = Vec2::new(200.0 * S, 200.0 * S); +const ARM_SIZE: Vec2 = Vec2::new(250.0 * S, 250.0 * S); +const LEG_SIZE: Vec2 = Vec2::new(200.0 * S, 350.0 * S); +const WEAPON_SIZE: Vec2 = Vec2::new(550.0 * S, 200.0 * S); +/// Weapon-Collision-Box: 60 % des Sprites. +const WEAPON_COLLIDER: Vec2 = Vec2::new(550.0 * S * 0.6, 200.0 * S * 0.6); + +// --------------------------------------------------------------------------- +// Chest-Joint-Offsets vom Chest-Sprite-Zentrum (Chest-Local, y-up, ×S) +// Chest: 250×250 → Zentrum (125,125) +// Formel: ((px-125)*S, -(py-125)*S) +// --------------------------------------------------------------------------- +const CHEST_HEAD_JOINT: Vec2 = Vec2::new((147.0-125.0)*S, -(47.0 -125.0)*S); +const CHEST_LARM_JOINT: Vec2 = Vec2::new((198.0-125.0)*S, -(82.0 -125.0)*S); +const CHEST_RARM_JOINT: Vec2 = Vec2::new((71.0 -125.0)*S, -(86.0 -125.0)*S); +const CHEST_RLEG_JOINT: Vec2 = Vec2::new((109.0-125.0)*S, -(232.0-125.0)*S); +const CHEST_LLEG_JOINT: Vec2 = Vec2::new((179.0-125.0)*S, -(220.0-125.0)*S); + +// --------------------------------------------------------------------------- +// Limb-Pivot-Offsets vom Glied-Sprite-Zentrum (Limb-Local, y-up, ×S) +// Head 200×200 → (100,100) | Arms 250×250 → (125,125) | Legs 200×350 → (100,175) +// --------------------------------------------------------------------------- +const HEAD_PIVOT: Vec2 = Vec2::new((87.0 -100.0)*S, -(147.0-100.0)*S); +const LARM_PIVOT: Vec2 = Vec2::new((72.0 -125.0)*S, -(71.0 -125.0)*S); +const RARM_PIVOT: Vec2 = Vec2::new((140.0-125.0)*S, -(72.0 -125.0)*S); +const RLEG_PIVOT: Vec2 = Vec2::new((142.0-100.0)*S, -(66.0 -175.0)*S); +const LLEG_PIVOT: Vec2 = Vec2::new((36.0 -100.0)*S, -(49.0 -175.0)*S); + +// --------------------------------------------------------------------------- +// System: spawn_ragdoll +// --------------------------------------------------------------------------- +pub(super) fn spawn_ragdoll( + mut commands: Commands, + asset_server: Res, + mut events: EventReader, + mut players: Query<(Entity, &mut Visibility, &Sprite), With>, +) { + for event in events.read() { + let Ok((player_entity, mut visibility, sprite)) = players.get_single_mut() else { continue; }; + *visibility = Visibility::Hidden; + + // Bobs Physics-Körper entfernen damit der Ragdoll-Chest nicht damit + // kollidiert (beide wären sonst im selben Default-CollisionLayer). + commands.entity(player_entity).remove::<( + RigidBody, + Collider, + LinearVelocity, + AngularVelocity, + )>(); + + let pos = event.player_position; + let is_flipped = sprite.flip_x; + let xf: f32 = if is_flipped { -1.0 } else { 1.0 }; + + let kill_dir = event.killer_position + .map(|kp| { + let d = pos - kp; + if d.length_squared() > f32::EPSILON { d.normalize() } else { Vec2::Y } + }) + .unwrap_or(Vec2::Y); + + let bvx = kill_dir.x * 280.0; + let bvy = kill_dir.y.abs() * 180.0 + 420.0; + + // Chest: avian2d übernimmt Gravity + Boden-Collision. + // Collider = ganzes PNG als Quadrat (CHEST_SIZE). + let chest_id = commands.spawn(( + Name::new("RagdollChest"), + Sprite { + image: asset_server.load("bob/rigging/Bob-Chest.png"), + custom_size: Some(CHEST_SIZE), + flip_x: is_flipped, + ..default() + }, + Transform::from_translation(pos.extend(RAGDOLL_Z + 0.2)), + RigidBody::Dynamic, + Collider::rectangle(CHEST_COLLIDER.x, CHEST_COLLIDER.y), + LinearVelocity(Vec2::new(bvx, bvy)), + AngularVelocity(1.8 * xf), + RagdollChest, + GameViewEntity, + )).id(); + + // Z-Layering: vordere Gliedmaßen über Chest (höheres Z), hintere darunter. + // Blickrichtung rechts (not flipped): rechte Gliedmaßen = vorne. + // Blickrichtung links (flipped): linke Gliedmaßen = vorne. + let z_back = RAGDOLL_Z + 0.1; // hinter dem Chest + let z_chest = RAGDOLL_Z + 0.2; // Chest-Referenz (oben gesetzt) + let z_front = RAGDOLL_Z + 0.35; // vor dem Chest + let z_head = RAGDOLL_Z + 0.45; + let z_weapon = RAGDOLL_Z + 0.5; + let _ = z_chest; // wird nur zur Dokumentation referenziert + + // facing right → right = front | facing left → left = front + let (larm_z, rarm_z, lleg_z, rleg_z) = if is_flipped { + (z_front, z_back, z_front, z_back) + } else { + (z_back, z_front, z_back, z_front) + }; + + // Hilfsfunktion: X-Achse spiegeln wenn Bob nach links schaut + let fx = |v: Vec2| Vec2::new(v.x * xf, v.y); + + // Zufällige Startrotation pro Gliedmaße (deterministisch via chest-Entity-Index) + // Bereich: ±(MAX_JOINT_ANGLE_RAD * 0.9) — also knapp unter dem Limit + let seed = chest_id.index(); + let rand_rot = |s: u32| -> f32 { + (hash_to_unit(seed.wrapping_add(s)) * 2.0 - 1.0) * MAX_JOINT_ANGLE_RAD * 0.9 + }; + + // Gliedmaßen spawnen (Joint-Constraint an Chest) + spawn_limb(&mut commands, &asset_server, + "bob/rigging/Bob-Head.png", + z_head, HEAD_SIZE, pos, + fx(CHEST_HEAD_JOINT), fx(HEAD_PIVOT), -3.5 * xf, rand_rot(11), is_flipped, chest_id); + + spawn_limb(&mut commands, &asset_server, + "bob/rigging/Bob-Left-Arm.png", + larm_z, ARM_SIZE, pos, + fx(CHEST_LARM_JOINT), fx(LARM_PIVOT), 5.0 * xf, rand_rot(23), is_flipped, chest_id); + + spawn_limb(&mut commands, &asset_server, + "bob/rigging/Bob-Right-Arm.png", + rarm_z, ARM_SIZE, pos, + fx(CHEST_RARM_JOINT), fx(RARM_PIVOT), -4.5 * xf, rand_rot(37), is_flipped, chest_id); + + spawn_limb(&mut commands, &asset_server, + "bob/rigging/Bob-Right-Leg.png", + rleg_z, LEG_SIZE, pos, + fx(CHEST_RLEG_JOINT), fx(RLEG_PIVOT), 7.0 * xf, rand_rot(53), is_flipped, chest_id); + + spawn_limb(&mut commands, &asset_server, + "bob/rigging/Bob-Left-Leg.png", + lleg_z, LEG_SIZE, pos, + fx(CHEST_LLEG_JOINT), fx(LLEG_PIVOT), -6.0 * xf, rand_rot(71), is_flipped, chest_id); + + // Waffe – avian2d-Physics (Dynamic), kollidiert mit Boden + NPCs. + // Bobs Collider ist zu diesem Zeitpunkt bereits entfernt worden, + // daher trifft die Waffe den Spieler de facto nicht. + commands.spawn(( + Name::new("RagdollWeapon"), + Sprite { + image: asset_server.load("bob/rigging/Bob-Weapon.png"), + custom_size: Some(WEAPON_SIZE), + flip_x: is_flipped, + ..default() + }, + Transform::from_translation( + (pos + Vec2::new(55.0 * xf, 10.0)).extend(z_weapon), + ), + RigidBody::Dynamic, + Collider::rectangle(WEAPON_COLLIDER.x, WEAPON_COLLIDER.y), + LinearVelocity(Vec2::new(bvx * 1.5 + 130.0 * xf, bvy * 0.6 + 150.0)), + AngularVelocity(10.0 * xf), + RagdollWeapon, + GameViewEntity, + )); + } +} + +/// Spawnt eine Gliedmaße. Startposition: Chest-Joint − Limb-Pivot (bei Rotation 0). +#[allow(clippy::too_many_arguments)] +fn spawn_limb( + commands: &mut Commands, + asset_server: &AssetServer, + path: &str, + z: f32, + size: Vec2, + chest_world_pos: Vec2, + chest_joint_local: Vec2, + limb_pivot_local: Vec2, + angular_velocity: f32, + initial_rot_z: f32, + flip_x: bool, + chest_id: Entity, +) { + let init_pos = chest_world_pos + chest_joint_local - limb_pivot_local; + commands.spawn(( + Name::new(format!("RagdollLimb:{path}")), + Sprite { + image: asset_server.load(path), + custom_size: Some(size), + flip_x, + ..default() + }, + Transform::from_translation(init_pos.extend(z)) + .with_rotation(Quat::from_rotation_z(initial_rot_z)), + RagdollLimb { + angular_velocity, + chest_entity: Some(chest_id), + chest_joint_local, + limb_pivot_local, + }, + GameViewEntity, + )); +} + +// --------------------------------------------------------------------------- +// System: update_ragdoll_parts +// --------------------------------------------------------------------------- +pub(super) fn update_ragdoll_parts( + mut commands: Commands, + time: Res