diff --git a/Cargo.toml b/Cargo.toml index c1029d98c..95c6a963d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ resolver = "2" "src/lib/core", "src/lib/core/state", "src/lib/derive_macros", + "src/lib/entities", "src/lib/net", "src/lib/net/crates/codec", "src/lib/net/crates/encryption", @@ -98,6 +99,7 @@ ferrumc-anvil = { path = "src/lib/adapters/anvil" } ferrumc-config = { path = "src/lib/config" } ferrumc-core = { path = "src/lib/core" } ferrumc-default-commands = { path = "src/lib/default_commands" } +ferrumc-entities = { path = "src/lib/entities" } ferrumc-commands = { path = "src/lib/commands" } ferrumc-general-purpose = { path = "src/lib/utils/general_purpose" } ferrumc-logging = { path = "src/lib/utils/logging" } diff --git a/src/bin/Cargo.toml b/src/bin/Cargo.toml index 33f17ade1..b821b82ad 100644 --- a/src/bin/Cargo.toml +++ b/src/bin/Cargo.toml @@ -12,6 +12,7 @@ thiserror = { workspace = true } ferrumc-core = { workspace = true } bevy_ecs = { workspace = true } ferrumc-scheduler = { workspace = true } +ferrumc-entities = { workspace = true } ferrumc-registry = { workspace = true } ferrumc-events = { workspace = true } diff --git a/src/bin/src/packet_handlers/play_packets/interact_entity.rs b/src/bin/src/packet_handlers/play_packets/interact_entity.rs new file mode 100644 index 000000000..62301b1eb --- /dev/null +++ b/src/bin/src/packet_handlers/play_packets/interact_entity.rs @@ -0,0 +1,85 @@ +use bevy_ecs::prelude::{EventWriter, Query, Res}; +use ferrumc_core::identity::player_identity::PlayerIdentity; +use ferrumc_core::transform::position::Position; +use ferrumc_entities::DamageEvent; +use ferrumc_entities::EntityNetworkIdIndex; +use ferrumc_net::InteractEntityPacketReceiver; +use ferrumc_state::GlobalStateResource; +use tracing::{debug, warn}; + +pub fn handle( + events: Res, + player_query: Query<(&PlayerIdentity, &Position)>, + entity_query: Query<&Position>, + entity_index: Res, + mut damage_events: EventWriter, + _state: Res, +) { + for (event, player_eid) in events.0.try_iter() { + // Get player identity and position + let Ok((player_identity, player_pos)) = player_query.get(player_eid) else { + warn!( + "Player identity/position not found for entity {:?}", + player_eid + ); + continue; + }; + + // Check if this is an attack interaction + if !event.is_attack() { + debug!( + "Non-attack interaction type {} (not implemented yet)", + event.interaction_type.0 + ); + continue; + } + + // Fast O(1) lookup using network ID index + let target_network_id = event.entity_id.0; + let Some(target) = entity_index.get(target_network_id) else { + warn!( + "Player {} attacked non-existent entity with network ID {}", + player_identity.short_uuid, target_network_id + ); + continue; + }; + + // Get target position + let Ok(target_pos) = entity_query.get(target) else { + warn!("Target entity {:?} missing Position component", target); + continue; + }; + + // Calculate knockback direction (from attacker to target, normalized) + let dx = target_pos.x - player_pos.x; + let dz = target_pos.z - player_pos.z; + let horizontal_dist = (dx * dx + dz * dz).sqrt(); + + let knockback_direction = if horizontal_dist > 0.0 { + Some((dx / horizontal_dist, 0.0, dz / horizontal_dist)) + } else { + None + }; + + // Base damage for unarmed attack in Minecraft is 1.0 + // TODO: Calculate damage based on held item, enchantments, critical hits, etc. + let base_damage = 1.0; + + // Base knockback strength (Minecraft default) + let knockback_strength = 0.4; + + debug!( + "Player {} attacked entity {:?} (network ID: {}, sneaking: {}) - Damage: {}, Knockback: {:?}", + player_identity.short_uuid, target, target_network_id, event.sneaking, base_damage, knockback_direction + ); + + // Write damage event + damage_events.write(DamageEvent { + target, + attacker: Some(player_eid), + damage: base_damage, + knockback_direction, + knockback_strength, + }); + } +} diff --git a/src/bin/src/packet_handlers/play_packets/mod.rs b/src/bin/src/packet_handlers/play_packets/mod.rs index f408c4487..b5db1049c 100644 --- a/src/bin/src/packet_handlers/play_packets/mod.rs +++ b/src/bin/src/packet_handlers/play_packets/mod.rs @@ -6,6 +6,7 @@ mod chunk_batch_ack; mod command; mod command_suggestions; mod confirm_player_teleport; +mod interact_entity; mod keep_alive; mod pick_item_from_block; mod place_block; @@ -23,6 +24,7 @@ pub fn register_packet_handlers(schedule: &mut Schedule) { // which one schedule.add_systems(chunk_batch_ack::handle); schedule.add_systems(confirm_player_teleport::handle); + schedule.add_systems(interact_entity::handle); schedule.add_systems(keep_alive::handle); schedule.add_systems(place_block::handle); schedule.add_systems(player_action::handle); diff --git a/src/bin/src/register_events.rs b/src/bin/src/register_events.rs index 91146cfb4..c05b515e4 100644 --- a/src/bin/src/register_events.rs +++ b/src/bin/src/register_events.rs @@ -3,6 +3,7 @@ use bevy_ecs::prelude::World; use ferrumc_commands::events::{CommandDispatchEvent, ResolvedCommandDispatchEvent}; use ferrumc_core::chunks::cross_chunk_boundary_event::CrossChunkBoundaryEvent; use ferrumc_core::conn::force_player_recount_event::ForcePlayerRecountEvent; +use ferrumc_entities::{DamageEvent, SpawnEntityEvent}; use ferrumc_events::*; use ferrumc_net::packets::packet_events::TransformEvent; @@ -24,4 +25,6 @@ pub fn register_events(world: &mut World) { EventRegistry::register_event::(world); EventRegistry::register_event::(world); EventRegistry::register_event::(world); + EventRegistry::register_event::(world); + EventRegistry::register_event::(world); } diff --git a/src/bin/src/register_resources.rs b/src/bin/src/register_resources.rs index f1723e5c4..1c6edc524 100644 --- a/src/bin/src/register_resources.rs +++ b/src/bin/src/register_resources.rs @@ -3,6 +3,7 @@ use bevy_ecs::prelude::World; use crossbeam_channel::Receiver; use ferrumc_core::chunks::world_sync_tracker::WorldSyncTracker; use ferrumc_core::conn::player_count_update_cooldown::PlayerCountUpdateCooldown; +use ferrumc_entities::EntityNetworkIdIndex; use ferrumc_net::connection::NewConnection; use ferrumc_state::GlobalStateResource; @@ -19,4 +20,5 @@ pub fn register_resources( world.insert_resource(WorldSyncTracker { last_synced: std::time::Instant::now(), }); + world.insert_resource(EntityNetworkIdIndex::new()); } diff --git a/src/bin/src/systems/entities/entity_damage.rs b/src/bin/src/systems/entities/entity_damage.rs new file mode 100644 index 000000000..13c6da6aa --- /dev/null +++ b/src/bin/src/systems/entities/entity_damage.rs @@ -0,0 +1,85 @@ +use bevy_ecs::prelude::*; +use ferrumc_core::identity::player_identity::PlayerIdentity; +use ferrumc_entities::components::{EntityId, EntityType, Health, Velocity}; +use ferrumc_entities::DamageEvent; +use ferrumc_net::connection::StreamWriter; +use ferrumc_net::packets::outgoing::entity_sound_effect::EntitySoundEffectPacket; +use ferrumc_state::GlobalStateResource; +use tracing::{debug, info}; + +/// System that processes damage events and applies damage + knockback to entities +pub fn entity_damage_system( + mut damage_events: EventReader, + mut entity_query: Query<(&EntityId, &EntityType, &mut Health, &mut Velocity)>, + player_query: Query<(Entity, &StreamWriter), With>, + state: Res, +) { + for event in damage_events.read() { + // Get the target entity's components + let Ok((entity_id, entity_type, mut health, mut velocity)) = + entity_query.get_mut(event.target) + else { + debug!( + "Damage event target entity {:?} not found or missing components", + event.target + ); + continue; + }; + + // Apply damage + health.damage(event.damage); + + info!( + "Entity {} took {} damage (HP: {}/{})", + entity_id.to_network_id(), + event.damage, + health.current, + health.max + ); + + // Apply knockback if direction is specified + if let Some((kx, _ky, kz)) = event.knockback_direction { + let strength = event.knockback_strength; + velocity.x += kx * strength; + velocity.y += 0.4; // Minecraft vanilla: always add upward velocity on hit + velocity.z += kz * strength; + + debug!( + "Applied knockback to entity {}: velocity ({:.2}, {:.2}, {:.2})", + entity_id.to_network_id(), + velocity.x, + velocity.y, + velocity.z + ); + } + + // Send hurt sound effect to all connected players + let sound_id = match entity_type { + EntityType::Pig => 1114, // entity.pig.hurt + // TODO: Add more entity types and their hurt sounds + _ => { + debug!("No hurt sound defined for {:?}", entity_type); + continue; // Skip sound if not defined + } + }; + + let sound_packet = EntitySoundEffectPacket::hurt(sound_id, entity_id.to_network_id()); + + for (player_entity, stream_writer) in player_query.iter() { + if state.0.players.is_connected(player_entity) { + if let Err(e) = stream_writer.send_packet_ref(&sound_packet) { + debug!("Failed to send hurt sound to player: {}", e); + } + } + } + + debug!( + "Sent hurt sound {} for entity {}", + sound_id, + entity_id.to_network_id() + ); + + // TODO: Send damage animation packet to nearby players + // TODO: Apply invulnerability ticks (prevent damage spam) + } +} diff --git a/src/bin/src/systems/entities/entity_death.rs b/src/bin/src/systems/entities/entity_death.rs new file mode 100644 index 000000000..ff3587a62 --- /dev/null +++ b/src/bin/src/systems/entities/entity_death.rs @@ -0,0 +1,57 @@ +use bevy_ecs::prelude::*; +use ferrumc_core::identity::player_identity::PlayerIdentity; +use ferrumc_entities::components::{EntityId, EntityType, Health}; +use ferrumc_entities::EntityNetworkIdIndex; +use ferrumc_net::connection::StreamWriter; +use ferrumc_net::packets::outgoing::remove_entities::RemoveEntitiesPacket; +use ferrumc_net_codec::net_types::length_prefixed_vec::LengthPrefixedVec; +use ferrumc_net_codec::net_types::var_int::VarInt; +use ferrumc_state::GlobalStateResource; +use tracing::{error, info}; + +/// System that checks for dead entities and despawns them +/// Works for all entity types (pigs, zombies, etc.) +pub fn entity_death_system( + mut commands: Commands, + entity_query: Query<(Entity, &EntityId, &EntityType, &Health)>, + player_query: Query<(Entity, &StreamWriter), With>, + mut entity_index: ResMut, + state: Res, +) { + for (entity, entity_id, entity_type, health) in entity_query.iter() { + if health.is_dead() { + info!( + "Entity {:?} (ID: {}) died (HP: {}/{}), despawning...", + entity_type, + entity_id.to_network_id(), + health.current, + health.max + ); + + // Send RemoveEntitiesPacket to all connected players + let network_id = entity_id.to_network_id(); + let remove_packet = RemoveEntitiesPacket { + entity_ids: LengthPrefixedVec::new(vec![VarInt::new(network_id)]), + }; + + for (player_entity, stream_writer) in player_query.iter() { + // Check if player is still connected + if !state.0.players.is_connected(player_entity) { + continue; + } + + if let Err(e) = stream_writer.send_packet_ref(&remove_packet) { + error!("Failed to send remove entity packet: {}", e); + } + } + + // Remove from network ID index + entity_index.remove(network_id); + + // Despawn the entity from ECS + commands.entity(entity).despawn(); + + info!("Entity {} successfully despawned", network_id); + } + } +} diff --git a/src/bin/src/systems/entities/entity_movement.rs b/src/bin/src/systems/entities/entity_movement.rs new file mode 100644 index 000000000..e59675a08 --- /dev/null +++ b/src/bin/src/systems/entities/entity_movement.rs @@ -0,0 +1,101 @@ +use bevy_ecs::prelude::*; +use ferrumc_core::transform::grounded::OnGround; +use ferrumc_core::transform::position::Position; +use ferrumc_entities::collision::{check_collision, is_in_water, BoundingBox}; +use ferrumc_entities::components::*; +use ferrumc_state::GlobalStateResource; + +const GRAVITY: f64 = -0.08; // Blocks per tick^2 +const TERMINAL_VELOCITY: f64 = -3.92; // Max fall speed +const WATER_BUOYANCY: f64 = 0.09; // Upward force in water (slightly stronger than gravity for gentle floating) +const WATER_DRAG: f64 = 0.8; // Water friction multiplier + +/// System that apply basic physics to entity +pub fn entity_physics_system( + mut query: Query<(&mut Position, &mut Velocity, &OnGround), With>, + state: Res, +) { + // TODO: Make this configurable per entity type + let bbox = BoundingBox::PIG; + + for (mut pos, mut vel, on_ground) in query.iter_mut() { + // Check if entity is in water + let in_water = is_in_water(&state.0, pos.x, pos.y, pos.z, &bbox); + + // Apply gravity and buoyancy + if in_water { + // In water: buoyancy force is slightly stronger than gravity for gentle floating + vel.y += GRAVITY + WATER_BUOYANCY; + // Net force: -0.08 + 0.09 = +0.01 (upward), causing gentle floating + } else if !on_ground.0 { + // In air: normal gravity + vel.y = (vel.y + GRAVITY).max(TERMINAL_VELOCITY); + } else { + // On ground: reset downward velocity + if vel.y < 0.0 { + vel.y = 0.0; + } + } + + // Try to move in all three axes, checking collision at the final position + let new_x = pos.x + vel.x; + let new_y = pos.y + vel.y; + let new_z = pos.z + vel.z; + + // Check collision at the new position (considering all movement) + if !check_collision(&state.0, new_x, new_y, new_z, &bbox) { + // No collision, move freely + *pos = Position::new(new_x, new_y, new_z); + } else { + // Collision detected, try each axis separately + let mut final_x = pos.x; + let mut final_y = pos.y; + let mut final_z = pos.z; + + // Try Y movement first (jumping/falling) + if !check_collision(&state.0, pos.x, new_y, pos.z, &bbox) { + final_y = new_y; + } else { + vel.y = 0.0; + } + + // Try X movement with updated Y position + if !check_collision(&state.0, new_x, final_y, pos.z, &bbox) { + final_x = new_x; + } else { + vel.x = 0.0; + } + + // Try Z movement with updated X and Y positions + if !check_collision(&state.0, final_x, final_y, new_z, &bbox) { + final_z = new_z; + } else { + vel.z = 0.0; + } + + *pos = Position::new(final_x, final_y, final_z); + } + + // Apply friction based on environment + if in_water { + // Water drag - slows movement significantly + vel.x *= WATER_DRAG; + vel.z *= WATER_DRAG; + vel.y *= 0.95; // Vertical water drag + } else if on_ground.0 { + // Ground friction + vel.x *= 0.85; + vel.z *= 0.85; + } else { + // Air resistance + vel.x *= 0.98; + vel.z *= 0.98; + } + } +} + +pub fn entity_age_system(mut query: Query<&mut Age, With>) { + for mut age in query.iter_mut() { + age.tick(); + } +} diff --git a/src/bin/src/systems/entities/entity_movement_sync.rs b/src/bin/src/systems/entities/entity_movement_sync.rs new file mode 100644 index 000000000..80799d919 --- /dev/null +++ b/src/bin/src/systems/entities/entity_movement_sync.rs @@ -0,0 +1,62 @@ +use bevy_ecs::prelude::*; +use ferrumc_core::identity::player_identity::PlayerIdentity; +use ferrumc_core::transform::grounded::OnGround; +use ferrumc_core::transform::position::Position; +use ferrumc_core::transform::rotation::Rotation; +use ferrumc_entities::components::*; +use ferrumc_net::connection::StreamWriter; +use ferrumc_net::packets::outgoing::update_entity_position_and_rotation::UpdateEntityPositionAndRotationPacket; +use ferrumc_net_codec::net_types::var_int::VarInt; +use tracing::error; + +type EntitySyncComponents<'a> = ( + &'a EntityId, + &'a Position, + &'a Rotation, + &'a OnGround, + &'a SyncedToPlayers, + &'a mut LastSyncedPosition, +); + +type EntitySyncFilter = (With, Without); + +/// System that syncs entity movement to players +pub fn entity_movement_sync_system( + mut entities: Query, + players: Query<&StreamWriter, With>, +) { + for (entity_id, pos, rot, on_ground, synced, mut last_pos) in entities.iter_mut() { + // Only sync if entity has moved + if !last_pos.has_moved(pos) { + continue; + } + + let delta = last_pos.delta_to(pos); + + // Send update to all players who have this entity spawned + for player_entity in &synced.player_entities { + if let Ok(stream_writer) = players.get(*player_entity) { + let packet = UpdateEntityPositionAndRotationPacket { + entity_id: VarInt::new(entity_id.to_network_id()), + delta_x: delta.0, + delta_y: delta.1, + delta_z: delta.2, + yaw: ferrumc_net_codec::net_types::angle::NetAngle::from_degrees( + rot.yaw as f64, + ), + pitch: ferrumc_net_codec::net_types::angle::NetAngle::from_degrees( + rot.pitch as f64, + ), + on_ground: on_ground.0, + }; + + if let Err(e) = stream_writer.send_packet(packet) { + error!("Failed to send entity movement packet: {:?}", e); + } + } + } + + // Update last synced position + *last_pos = LastSyncedPosition::from_position(pos); + } +} diff --git a/src/bin/src/systems/entities/entity_spawner.rs b/src/bin/src/systems/entities/entity_spawner.rs new file mode 100644 index 000000000..45955c7e0 --- /dev/null +++ b/src/bin/src/systems/entities/entity_spawner.rs @@ -0,0 +1,40 @@ +use bevy_ecs::prelude::*; +use ferrumc_entities::{EntityNetworkIdIndex, SpawnEntityEvent}; +use ferrumc_state::GlobalStateResource; +use std::sync::atomic::Ordering; +use tracing::info; + +/// System that listen spawn event and create entity +pub fn entity_spawner_system( + mut commands: Commands, + mut spawn_events: EventReader, + mut entity_index: ResMut, + _global_state: Res, +) { + for event in spawn_events.read() { + // Generate new entity ID + let entity_id = generate_entity_id(); + + // Delegate spawning to EntityType + if let Some(entity) = event + .entity_type + .spawn(&mut commands, entity_id, &event.position) + { + // Add to network ID index for O(1) lookup + entity_index.insert(entity_id as i32, entity); + + info!( + "Spawned {:?} with ID {} at ({:.2}, {:.2}, {:.2})", + event.entity_type, entity_id, event.position.x, event.position.y, event.position.z + ); + } + } +} + +// TODO: Implement true ID generator (for now using atomic counter) +// Using i64 to reduce collision risk on large servers with many entities +static NEXT_ENTITY_ID: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(1000); + +fn generate_entity_id() -> i64 { + NEXT_ENTITY_ID.fetch_add(1, Ordering::Relaxed) +} diff --git a/src/bin/src/systems/entities/entity_sync.rs b/src/bin/src/systems/entities/entity_sync.rs new file mode 100644 index 000000000..12fef9b06 --- /dev/null +++ b/src/bin/src/systems/entities/entity_sync.rs @@ -0,0 +1,110 @@ +use bevy_ecs::prelude::*; +use ferrumc_core::identity::player_identity::PlayerIdentity; +use ferrumc_core::transform::position::Position; +use ferrumc_core::transform::rotation::Rotation; +use ferrumc_entities::components::*; // Includes SyncedToPlayers +use ferrumc_entities::types::passive::pig::EntityUuid; +use ferrumc_net::connection::StreamWriter; +use ferrumc_net::packets::outgoing::entity_metadata::{EntityMetadata, EntityMetadataPacket}; +use ferrumc_net::packets::outgoing::spawn_entity::SpawnEntityPacket; +use tracing::{debug, error}; + +// Type alias to simplify the complex Query type +type EntitySyncQuery<'a> = ( + Entity, + &'a EntityType, + &'a EntityId, + &'a EntityUuid, + &'a Position, + &'a Rotation, + &'a mut SyncedToPlayers, +); + +/// System that send new entities to players +pub fn entity_sync_system( + // All non-player entities they needed to be sync + mut entity_query: Query, Without>, + + // All connected players + player_query: Query<(Entity, &StreamWriter, &Position), With>, +) { + for (entity, entity_type, entity_id, entity_uuid, pos, rot, mut synced) in + entity_query.iter_mut() + { + for (player_entity, stream_writer, player_pos) in player_query.iter() { + // Skip if already send to the player + if synced.player_entities.contains(&player_entity) { + continue; + } + + // TODO: Check distance (render distance) + let distance = ((pos.x - player_pos.x).powi(2) + (pos.z - player_pos.z).powi(2)).sqrt(); + + if distance > 128.0 { + // 8 chunks de distance + continue; + } + + // Create and send spawn packet + let protocol_id = entity_type.protocol_id(); + + // TODO: INVESTIGATE PROTOCOL_ID OFFSET BUG + // There is a systematic -1 offset between what we send and what the client displays: + // - Registry says Pig=94, Phantom=93 + // - When sending 94, client displays Phantom (93) + // - When sending 95, client displays Pig (94) + // Possible causes: + // 1. Registry version mismatch (registry for 1.21.7 but client is 1.21.8?) + // 2. VarInt encoding issue + // 3. Protocol expects 1-based indexing instead of 0-based + // For now, adding +1 as a workaround until root cause is found + let adjusted_protocol_id = protocol_id + 1; + debug!( + "Spawning {:?} (registry_id={}, sending={}) at ({:.2}, {:.2}, {:.2}) for player {:?}", + entity_type, protocol_id, adjusted_protocol_id, pos.x, pos.y, pos.z, player_entity + ); + debug!( + "DEBUG: entity_id={}, uuid={}, type_id={}, data=0", + entity_id.to_network_id(), + entity_uuid.0.as_u128(), + adjusted_protocol_id + ); + + let spawn_packet = SpawnEntityPacket::entity( + entity_id.to_network_id(), + entity_uuid.0.as_u128(), + adjusted_protocol_id, + pos, + rot, + ); + + if let Err(e) = stream_writer.send_packet(spawn_packet) { + error!("Failed to send spawn packet: {:?}", e); + continue; + } + + // Send EntityMetadataPacket to properly display the entity + // We need to send both entity flags (index 0) and pose (index 6) + let metadata_packet = EntityMetadataPacket::new( + entity_id.as_varint(), + [ + EntityMetadata::entity_normal_state(), // Index 0: normal entity state (no special flags) + EntityMetadata::entity_standing(), // Index 6: standing pose + ], + ); + + if let Err(e) = stream_writer.send_packet(metadata_packet) { + error!("Failed to send entity metadata packet: {:?}", e); + continue; + } + + synced.player_entities.push(player_entity); + debug!( + "Successfully sent entity {:?} (ID: {}) to player {:?}", + entity, + entity_id.to_network_id(), + player_entity + ); + } + } +} diff --git a/src/bin/src/systems/entities/entity_tick.rs b/src/bin/src/systems/entities/entity_tick.rs new file mode 100644 index 000000000..25b43f336 --- /dev/null +++ b/src/bin/src/systems/entities/entity_tick.rs @@ -0,0 +1,2 @@ +// Re-export pig tick system from the pig module +pub use ferrumc_entities::types::passive::pig::pig_tick_system; diff --git a/src/bin/src/systems/entities/ground_check.rs b/src/bin/src/systems/entities/ground_check.rs new file mode 100644 index 000000000..ac09ce740 --- /dev/null +++ b/src/bin/src/systems/entities/ground_check.rs @@ -0,0 +1,23 @@ +use bevy_ecs::prelude::*; +use ferrumc_core::transform::grounded::OnGround; +use ferrumc_core::transform::position::Position; +use ferrumc_entities::collision::is_solid_block; +use ferrumc_entities::EntityType; +use ferrumc_state::GlobalStateResource; + +/// System that checks if entities are on the ground +/// Updates the OnGround component based on the block below the entity +pub fn ground_check_system( + mut query: Query<(&Position, &mut OnGround), With>, + state: Res, +) { + for (pos, mut on_ground) in query.iter_mut() { + let block_x = pos.x.floor() as i32; + let block_y = (pos.y - 0.1).floor() as i32; // Slightly below feet + let block_z = pos.z.floor() as i32; + + // Use shared collision helper + // TODO: Check for specific non-solid blocks (water, lava, tall grass, etc.) + on_ground.0 = is_solid_block(&state.0, block_x, block_y, block_z); + } +} diff --git a/src/bin/src/systems/entities/mod.rs b/src/bin/src/systems/entities/mod.rs new file mode 100644 index 000000000..487b8eaa5 --- /dev/null +++ b/src/bin/src/systems/entities/mod.rs @@ -0,0 +1,27 @@ +pub mod entity_damage; +pub mod entity_death; +pub mod entity_movement; +pub mod entity_movement_sync; +pub mod entity_spawner; +pub mod entity_sync; +pub mod entity_tick; +pub mod ground_check; +pub mod spawn_command_processor; + +use bevy_ecs::schedule::Schedule; + +/// Save all systems bind to entities +pub fn register_entity_systems(schedule: &mut Schedule) { + schedule.add_systems(( + spawn_command_processor::spawn_command_processor_system, // Process spawn commands from /spawnpig + entity_spawner::entity_spawner_system, + ground_check::ground_check_system, // Check if entities are on ground + entity_tick::pig_tick_system, // Tick AI/behavior for pigs + entity_damage::entity_damage_system, // Process damage events and apply knockback + entity_death::entity_death_system, // Check for dead entities and despawn them + entity_movement::entity_physics_system, // Apply physics (gravity, movement, knockback) + entity_movement::entity_age_system, + entity_sync::entity_sync_system, // Sync new entities to clients + entity_movement_sync::entity_movement_sync_system, // Sync entity movement to clients + )); +} diff --git a/src/bin/src/systems/entities/spawn_command_processor.rs b/src/bin/src/systems/entities/spawn_command_processor.rs new file mode 100644 index 000000000..c25c3575f --- /dev/null +++ b/src/bin/src/systems/entities/spawn_command_processor.rs @@ -0,0 +1,31 @@ +use bevy_ecs::prelude::*; +use ferrumc_core::transform::position::Position; +use ferrumc_entities::{pop_spawn_request, SpawnEntityEvent}; +use ferrumc_state::GlobalStateResource; +use tracing::warn; + +/// System that processes spawn commands from the queue and sends spawn events +pub fn spawn_command_processor_system( + query: Query<&Position>, + mut spawn_events: EventWriter, + _state: Res, +) { + // Process all pending spawn requests from the lock-free queue + while let Some(request) = pop_spawn_request() { + // Get player position + if let Ok(pos) = query.get(request.player_entity) { + // Spawn entity 2 blocks in front of the player at same Y level + let spawn_pos = Position::new(pos.x + 2.0, pos.y, pos.z + 2.0); + + spawn_events.write(SpawnEntityEvent { + entity_type: request.entity_type, + position: spawn_pos, + }); + } else { + warn!( + "Failed to get position for entity {:?}", + request.player_entity + ); + } + } +} diff --git a/src/bin/src/systems/mod.rs b/src/bin/src/systems/mod.rs index 322a214c8..68332cd9b 100644 --- a/src/bin/src/systems/mod.rs +++ b/src/bin/src/systems/mod.rs @@ -1,5 +1,6 @@ pub mod connection_killer; mod cross_chunk_boundary; +pub mod entities; pub mod keep_alive_system; pub mod lan_pinger; pub mod listneners; @@ -15,7 +16,7 @@ pub fn register_game_systems(schedule: &mut bevy_ecs::schedule::Schedule) { schedule.add_systems(new_connections::accept_new_connections); schedule.add_systems(cross_chunk_boundary::cross_chunk_boundary); schedule.add_systems(mq::process); - + entities::register_entity_systems(schedule); // Should always be last schedule.add_systems(connection_killer::connection_killer); } diff --git a/src/lib/default_commands/Cargo.toml b/src/lib/default_commands/Cargo.toml index aa4fa0ce3..454c04fdc 100644 --- a/src/lib/default_commands/Cargo.toml +++ b/src/lib/default_commands/Cargo.toml @@ -11,7 +11,7 @@ ferrumc-macros = { workspace = true } ferrumc-text = { workspace = true } ferrumc-core = { workspace = true } ferrumc-net = { workspace = true } - +ferrumc-entities = { workspace = true } ctor = { workspace = true } tracing = { workspace = true } bevy_ecs = { workspace = true } diff --git a/src/lib/default_commands/src/lib.rs b/src/lib/default_commands/src/lib.rs index 0ad76ff6c..98fb0c1e9 100644 --- a/src/lib/default_commands/src/lib.rs +++ b/src/lib/default_commands/src/lib.rs @@ -2,6 +2,7 @@ pub mod echo; pub mod fly; pub mod gamemode; pub mod nested; +pub mod spawn_entity; /// Static library initialisation shenanigans. pub fn init() {} diff --git a/src/lib/default_commands/src/spawn_entity.rs b/src/lib/default_commands/src/spawn_entity.rs new file mode 100644 index 000000000..679f2876a --- /dev/null +++ b/src/lib/default_commands/src/spawn_entity.rs @@ -0,0 +1,22 @@ +use ferrumc_commands::Sender; +use ferrumc_entities::{request_spawn, EntityType}; +use ferrumc_macros::command; +use ferrumc_text::TextComponent; + +#[command("spawnpig")] +fn spawn_pig_command(#[sender] sender: Sender) { + match sender { + Sender::Player(entity) => { + // Add spawn request to global queue - will be processed by spawn_command_processor system + request_spawn(EntityType::Pig, entity); + + sender.send_message(TextComponent::from("Pig spawned!"), false); + } + Sender::Server => { + sender.send_message( + TextComponent::from("Only players can use this command"), + false, + ); + } + }; +} diff --git a/src/lib/entities/Cargo.toml b/src/lib/entities/Cargo.toml new file mode 100644 index 000000000..f58238dba --- /dev/null +++ b/src/lib/entities/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "ferrumc-entities" +version = "0.1.0" +edition = "2024" + +[dependencies] +bevy_ecs = { workspace = true } + +ferrumc-core = { workspace = true } +ferrumc-data = { workspace = true } +ferrumc-macros = { workspace = true } +ferrumc-net = { workspace = true } +ferrumc-net-codec = { workspace = true } +ferrumc-state = { workspace = true } +ferrumc-world = { workspace = true } + +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } +rand = { workspace = true } +typename = { workspace = true } +once_cell = { workspace = true } +crossbeam-queue = { workspace = true } + +serde = { workspace = true } +serde_derive = { workspace = true } diff --git a/src/lib/entities/src/collision.rs b/src/lib/entities/src/collision.rs new file mode 100644 index 000000000..4418cad87 --- /dev/null +++ b/src/lib/entities/src/collision.rs @@ -0,0 +1,168 @@ +use ferrumc_core::transform::position::Position; +use ferrumc_data::generated::entities::EntityType as EntityTypeData; +use ferrumc_state::GlobalState; + +/// Bounding box dimensions for an entity +#[derive(Debug, Clone, Copy)] +pub struct BoundingBox { + pub half_width: f64, + pub height: f64, +} + +impl BoundingBox { + /// Pig hitbox from Minecraft vanilla data (0.9 × 0.9 blocks) + pub const PIG: BoundingBox = BoundingBox { + half_width: EntityTypeData::PIG.dimension[0] as f64 / 2.0, + height: EntityTypeData::PIG.dimension[1] as f64, + }; +} + +/// Check if a block is water +pub fn is_water_block(state: &GlobalState, x: i32, y: i32, z: i32) -> bool { + state + .world + .get_block_and_fetch(x, y, z, "overworld") + .map(|block_state| { + let id = block_state.0; + // Water is 86-101 + (86..=101).contains(&id) + }) + .unwrap_or(false) +} + +/// Check if there's a solid block at the given position +/// +/// Excludes water (86-101) and lava (102-117) as they are not solid for collision purposes. +/// Entities should fall through these blocks. +pub fn is_solid_block(state: &GlobalState, x: i32, y: i32, z: i32) -> bool { + state + .world + .get_block_and_fetch(x, y, z, "overworld") + .map(|block_state| { + let id = block_state.0; + // Air is 0, water is 86-101, lava is 102-117 + id != 0 && !(86..=117).contains(&id) + }) + .unwrap_or(false) +} + +/// Check if an entity with the given bounding box would collide with blocks at the position +/// +/// Checks 8 points: 4 corners at feet level and 4 corners at head level +pub fn check_collision(state: &GlobalState, x: f64, y: f64, z: f64, bbox: &BoundingBox) -> bool { + // Check corners of the bounding box at feet and head level + let check_positions = [ + // Feet level - 4 corners + ( + (x - bbox.half_width).floor() as i32, + y.floor() as i32, + (z - bbox.half_width).floor() as i32, + ), + ( + (x + bbox.half_width).floor() as i32, + y.floor() as i32, + (z - bbox.half_width).floor() as i32, + ), + ( + (x - bbox.half_width).floor() as i32, + y.floor() as i32, + (z + bbox.half_width).floor() as i32, + ), + ( + (x + bbox.half_width).floor() as i32, + y.floor() as i32, + (z + bbox.half_width).floor() as i32, + ), + // Head level - 4 corners + ( + (x - bbox.half_width).floor() as i32, + (y + bbox.height).floor() as i32, + (z - bbox.half_width).floor() as i32, + ), + ( + (x + bbox.half_width).floor() as i32, + (y + bbox.height).floor() as i32, + (z - bbox.half_width).floor() as i32, + ), + ( + (x - bbox.half_width).floor() as i32, + (y + bbox.height).floor() as i32, + (z + bbox.half_width).floor() as i32, + ), + ( + (x + bbox.half_width).floor() as i32, + (y + bbox.height).floor() as i32, + (z + bbox.half_width).floor() as i32, + ), + ]; + + for (check_x, check_y, check_z) in check_positions { + if is_solid_block(state, check_x, check_y, check_z) { + return true; + } + } + + false +} + +/// Check if there's an obstacle ahead in the movement direction +/// +/// Used by AI to detect walls. Checks feet and head level. +pub fn check_obstacle_ahead( + state: &GlobalState, + pos: &Position, + vel_x: f64, + vel_z: f64, + bbox: &BoundingBox, +) -> bool { + let check_distance = 0.6; // Look slightly ahead + let next_x = pos.x + vel_x.signum() * check_distance; + let next_z = pos.z + vel_z.signum() * check_distance; + + // Check at feet level and head level + let check_positions = [ + // Feet level + ( + next_x.floor() as i32, + pos.y.floor() as i32, + next_z.floor() as i32, + ), + // Head level + ( + next_x.floor() as i32, + (pos.y + bbox.height * 0.5).floor() as i32, + next_z.floor() as i32, + ), + ]; + + for (check_x, check_y, check_z) in check_positions { + if is_solid_block(state, check_x, check_y, check_z) { + return true; + } + } + + false +} + +/// Check if an entity is submerged in water +/// +/// Checks if the entity's body (from feet to head) is in water +pub fn is_in_water(state: &GlobalState, x: f64, y: f64, z: f64, bbox: &BoundingBox) -> bool { + // Check center of the entity at feet and mid-body level + let block_x = x.floor() as i32; + let block_z = z.floor() as i32; + + // Check feet level + let feet_y = y.floor() as i32; + if is_water_block(state, block_x, feet_y, block_z) { + return true; + } + + // Check mid-body level (waist height) + let mid_y = (y + bbox.height * 0.5).floor() as i32; + if is_water_block(state, block_x, mid_y, block_z) { + return true; + } + + false +} diff --git a/src/lib/entities/src/components/age.rs b/src/lib/entities/src/components/age.rs new file mode 100644 index 000000000..50cc0ada9 --- /dev/null +++ b/src/lib/entities/src/components/age.rs @@ -0,0 +1,20 @@ +use bevy_ecs::prelude::Component; +use typename::TypeName; + +/// age of an entity in ticks (1 tick = 50ms at 20 TPS) +#[derive(Debug, Clone, Component, TypeName, Default)] +pub struct Age(pub u64); + +impl Age { + pub fn new() -> Self { + Self(0) + } + + pub fn tick(&mut self) { + self.0 = self.0.saturating_add(1); + } + + pub fn as_seconds(&self) -> f64 { + self.0 as f64 / 20.0 + } +} diff --git a/src/lib/entities/src/components/entity_id.rs b/src/lib/entities/src/components/entity_id.rs new file mode 100644 index 000000000..22e608f31 --- /dev/null +++ b/src/lib/entities/src/components/entity_id.rs @@ -0,0 +1,23 @@ +use bevy_ecs::prelude::Component; +use typename::TypeName; + +/// Entity ID stored as i64 internally to reduce collision risk on large servers. +/// The network protocol only supports i32, so we truncate when sending to clients. +#[derive(Debug, Clone, Copy, Component, TypeName)] +pub struct EntityId(pub i64); + +impl EntityId { + pub fn new(id: i64) -> Self { + Self(id) + } + + /// Returns the network-safe i32 representation of this ID. + /// The protocol only supports 32-bit entity IDs, so we truncate. + pub fn to_network_id(&self) -> i32 { + self.0 as i32 + } + + pub fn as_varint(&self) -> ferrumc_net_codec::net_types::var_int::VarInt { + ferrumc_net_codec::net_types::var_int::VarInt::new(self.to_network_id()) + } +} diff --git a/src/lib/entities/src/components/entity_type.rs b/src/lib/entities/src/components/entity_type.rs new file mode 100644 index 000000000..85f2a16a9 --- /dev/null +++ b/src/lib/entities/src/components/entity_type.rs @@ -0,0 +1,101 @@ +use bevy_ecs::prelude::{Commands, Component, Entity}; +use ferrumc_core::transform::position::Position; +use ferrumc_macros::get_registry_entry; +use typename::TypeName; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Component, TypeName)] +pub enum EntityType { + // Passive mobs + Pig, + Cow, + Sheep, + Chicken, + + // Hostiles mobs + Zombie, + Spider, + Creeper, + Skeleton, + + // Other + ItemEntity, // Dropped item + ExperienceOrb, +} + +impl EntityType { + /// Returns the protocol ID for this entity type. + /// Uses compile-time registry lookups for zero-cost abstraction. + pub fn protocol_id(&self) -> i32 { + match self { + EntityType::Pig => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:pig") as i32 + } + EntityType::Cow => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:cow") as i32 + } + EntityType::Sheep => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:sheep") as i32 + } + EntityType::Chicken => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:chicken") as i32 + } + EntityType::Zombie => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:zombie") as i32 + } + EntityType::Spider => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:spider") as i32 + } + EntityType::Creeper => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:creeper") as i32 + } + EntityType::Skeleton => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:skeleton") as i32 + } + EntityType::ItemEntity => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:item") as i32 + } + EntityType::ExperienceOrb => { + get_registry_entry!("minecraft:entity_type.entries.minecraft:experience_orb") as i32 + } + } + } + + pub fn is_hostile(&self) -> bool { + matches!( + self, + EntityType::Zombie | EntityType::Spider | EntityType::Creeper | EntityType::Skeleton + ) + } + + pub fn is_passive(&self) -> bool { + matches!( + self, + EntityType::Pig | EntityType::Cow | EntityType::Sheep | EntityType::Chicken + ) + } + + /// Spawns this entity type with the given ID and position + /// Spawn an entity and return its ECS Entity ID for lookup indexing + pub fn spawn( + &self, + commands: &mut Commands, + entity_id: i64, + position: &Position, + ) -> Option { + use crate::components::SyncedToPlayers; + use crate::types::passive::pig::PigBundle; + + match self { + EntityType::Pig => { + let pig = + PigBundle::new(entity_id, Position::new(position.x, position.y, position.z)); + let entity = commands.spawn((pig, SyncedToPlayers::default())).id(); + Some(entity) + } + _ => { + tracing::warn!("Entity type {:?} not yet implemented for spawning", self); + None + } + } + } +} diff --git a/src/lib/entities/src/components/health.rs b/src/lib/entities/src/components/health.rs new file mode 100644 index 000000000..36f0a957d --- /dev/null +++ b/src/lib/entities/src/components/health.rs @@ -0,0 +1,31 @@ +use bevy_ecs::prelude::Component; +use typename::TypeName; + +/// Health of entity +#[derive(Debug, Clone, Component, TypeName)] +pub struct Health { + pub current: f32, + pub max: f32, +} + +impl Health { + pub fn new(max: f32) -> Self { + Self { current: max, max } + } + + pub fn damage(&mut self, amount: f32) { + self.current = (self.current - amount).max(0.0); + } + + pub fn heal(&mut self, amount: f32) { + self.current = (self.current + amount).min(self.max); + } + + pub fn is_dead(&self) -> bool { + self.current <= 0.0 + } + + pub fn percentage(&self) -> f32 { + self.current / self.max + } +} diff --git a/src/lib/entities/src/components/last_synced_position.rs b/src/lib/entities/src/components/last_synced_position.rs new file mode 100644 index 000000000..cc2359391 --- /dev/null +++ b/src/lib/entities/src/components/last_synced_position.rs @@ -0,0 +1,35 @@ +use bevy_ecs::prelude::Component; +use ferrumc_core::transform::position::Position; + +/// Component that tracks the last position synchronized to clients +#[derive(Component, Debug, Clone)] +pub struct LastSyncedPosition { + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl LastSyncedPosition { + pub fn from_position(pos: &Position) -> Self { + Self { + x: pos.x, + y: pos.y, + z: pos.z, + } + } + + /// Calculate delta in Minecraft protocol units (1/4096 of a block) + pub fn delta_to(&self, new_pos: &Position) -> (i16, i16, i16) { + const SCALE: f64 = 4096.0; + let dx = ((new_pos.x - self.x) * SCALE) as i16; + let dy = ((new_pos.y - self.y) * SCALE) as i16; + let dz = ((new_pos.z - self.z) * SCALE) as i16; + (dx, dy, dz) + } + + pub fn has_moved(&self, new_pos: &Position) -> bool { + (self.x - new_pos.x).abs() > 0.001 + || (self.y - new_pos.y).abs() > 0.001 + || (self.z - new_pos.z).abs() > 0.001 + } +} diff --git a/src/lib/entities/src/components/mod.rs b/src/lib/entities/src/components/mod.rs new file mode 100644 index 000000000..0655c150a --- /dev/null +++ b/src/lib/entities/src/components/mod.rs @@ -0,0 +1,17 @@ +pub mod age; +pub mod entity_id; +pub mod entity_type; +pub mod health; +pub mod last_synced_position; +pub mod persisted; +pub mod synced_to_players; +pub mod velocity; + +pub use age::Age; +pub use entity_id::EntityId; +pub use entity_type::EntityType; +pub use health::Health; +pub use last_synced_position::LastSyncedPosition; +pub use persisted::Persisted; +pub use synced_to_players::SyncedToPlayers; +pub use velocity::Velocity; diff --git a/src/lib/entities/src/components/persisted.rs b/src/lib/entities/src/components/persisted.rs new file mode 100644 index 000000000..907d1dfb4 --- /dev/null +++ b/src/lib/entities/src/components/persisted.rs @@ -0,0 +1,5 @@ +use bevy_ecs::prelude::Component; + +/// Marker component for entities that should be persisted to the database +#[derive(Debug, Clone, Copy, Component, Default)] +pub struct Persisted; diff --git a/src/lib/entities/src/components/synced_to_players.rs b/src/lib/entities/src/components/synced_to_players.rs new file mode 100644 index 000000000..d2cdb7f1b --- /dev/null +++ b/src/lib/entities/src/components/synced_to_players.rs @@ -0,0 +1,9 @@ +use bevy_ecs::prelude::{Component, Entity}; + +/// Component that tracks which players have already received this entity's spawn packet +/// +/// This prevents sending duplicate spawn packets to the same player. +#[derive(Debug, Clone, Component, Default)] +pub struct SyncedToPlayers { + pub player_entities: Vec, +} diff --git a/src/lib/entities/src/components/velocity.rs b/src/lib/entities/src/components/velocity.rs new file mode 100644 index 000000000..6e37901ed --- /dev/null +++ b/src/lib/entities/src/components/velocity.rs @@ -0,0 +1,30 @@ +use bevy_ecs::prelude::Component; +use typename::TypeName; + +/// velocity of an entity (m/s) +#[derive(Debug, Clone, Component, TypeName)] +pub struct Velocity { + pub x: f64, + pub y: f64, + pub z: f64, +} + +impl Velocity { + pub fn new(x: f64, y: f64, z: f64) -> Self { + Self { x, y, z } + } + + pub fn zero() -> Self { + Self::new(0.0, 0.0, 0.0) + } + + pub fn magnitude(&self) -> f64 { + (self.x * self.x + self.y * self.y + self.z * self.z).sqrt() + } +} + +impl Default for Velocity { + fn default() -> Self { + Self::zero() + } +} diff --git a/src/lib/entities/src/entity_lookup.rs b/src/lib/entities/src/entity_lookup.rs new file mode 100644 index 000000000..ae2f53abc --- /dev/null +++ b/src/lib/entities/src/entity_lookup.rs @@ -0,0 +1,44 @@ +use bevy_ecs::prelude::{Entity, Resource}; +use std::collections::HashMap; + +/// Fast O(1) lookup index for entities by their network ID +/// +/// This avoids O(n) linear scans when looking up entities from packets. +/// Maintained by entity spawn/death systems. +#[derive(Resource, Default)] +pub struct EntityNetworkIdIndex { + map: HashMap, +} + +impl EntityNetworkIdIndex { + pub fn new() -> Self { + Self { + map: HashMap::new(), + } + } + + /// Register an entity with its network ID + pub fn insert(&mut self, network_id: i32, entity: Entity) { + self.map.insert(network_id, entity); + } + + /// Lookup an entity by its network ID + pub fn get(&self, network_id: i32) -> Option { + self.map.get(&network_id).copied() + } + + /// Remove an entity from the index (called on despawn) + pub fn remove(&mut self, network_id: i32) { + self.map.remove(&network_id); + } + + /// Get the number of entities in the index + pub fn len(&self) -> usize { + self.map.len() + } + + /// Check if the index is empty + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } +} diff --git a/src/lib/entities/src/events.rs b/src/lib/entities/src/events.rs new file mode 100644 index 000000000..db3e112dc --- /dev/null +++ b/src/lib/entities/src/events.rs @@ -0,0 +1,25 @@ +use crate::components::EntityType; +use bevy_ecs::prelude::{Entity, Event}; +use ferrumc_core::transform::position::Position; + +/// Event for asking entity spawn +#[derive(Event)] +pub struct SpawnEntityEvent { + pub entity_type: EntityType, + pub position: Position, +} + +/// Event for dealing damage to an entity +#[derive(Event, Debug, Clone)] +pub struct DamageEvent { + /// The entity receiving the damage + pub target: Entity, + /// The entity dealing the damage (if any) + pub attacker: Option, + /// Amount of damage to deal + pub damage: f32, + /// Knockback direction (normalized vector) + pub knockback_direction: Option<(f64, f64, f64)>, + /// Knockback strength multiplier + pub knockback_strength: f64, +} diff --git a/src/lib/entities/src/game_entity.rs b/src/lib/entities/src/game_entity.rs new file mode 100644 index 000000000..59a26fb49 --- /dev/null +++ b/src/lib/entities/src/game_entity.rs @@ -0,0 +1,11 @@ +use bevy_ecs::prelude::Commands; +use ferrumc_state::GlobalState; +use typename::TypeName; + +/// Trait for game entities that have behavior/AI +/// Each entity type can implement tick() to update its state +pub trait GameEntity: Send + Sync + TypeName + 'static { + fn tick(&mut self, _state: &GlobalState, _commands: &mut Commands) { + // Default: no-op for stateless entities + } +} diff --git a/src/lib/entities/src/lib.rs b/src/lib/entities/src/lib.rs new file mode 100644 index 000000000..8bf766180 --- /dev/null +++ b/src/lib/entities/src/lib.rs @@ -0,0 +1,14 @@ +pub mod collision; +pub mod components; +pub mod entity_lookup; +pub mod events; +pub mod game_entity; +pub mod spawn_command_queue; +pub mod types; + +// Re-export principals items for easyer usage +pub use components::{Age, EntityId, EntityType, Health, Persisted, Velocity}; +pub use entity_lookup::EntityNetworkIdIndex; +pub use events::{DamageEvent, SpawnEntityEvent}; +pub use game_entity::GameEntity; +pub use spawn_command_queue::{SpawnRequest, pop_spawn_request, request_spawn}; diff --git a/src/lib/entities/src/spawn_command_queue.rs b/src/lib/entities/src/spawn_command_queue.rs new file mode 100644 index 000000000..0859eb667 --- /dev/null +++ b/src/lib/entities/src/spawn_command_queue.rs @@ -0,0 +1,29 @@ +use crate::components::EntityType; +use bevy_ecs::prelude::Entity; +use crossbeam_queue::SegQueue; +use once_cell::sync::Lazy; + +/// Request to spawn an entity via command +#[derive(Debug)] +pub struct SpawnRequest { + pub entity_type: EntityType, + pub player_entity: Entity, // Entity of the player who issued the command +} + +/// Global lock-free queue for spawn requests from commands +/// Uses SegQueue for better performance than Mutex +static GLOBAL_SPAWN_QUEUE: Lazy> = Lazy::new(SegQueue::new); + +/// Add a spawn request to the global queue (lock-free operation) +pub fn request_spawn(entity_type: EntityType, player_entity: Entity) { + GLOBAL_SPAWN_QUEUE.push(SpawnRequest { + entity_type, + player_entity, + }); +} + +/// Pop a single spawn request from the global queue +/// Returns None if the queue is empty +pub fn pop_spawn_request() -> Option { + GLOBAL_SPAWN_QUEUE.pop() +} diff --git a/src/lib/entities/src/types/mod.rs b/src/lib/entities/src/types/mod.rs new file mode 100644 index 000000000..b820f9fd5 --- /dev/null +++ b/src/lib/entities/src/types/mod.rs @@ -0,0 +1,6 @@ +// Module for implement specific entities type +pub mod passive; + +// TODO +// pub mod hostile; +// pub mod neutral; diff --git a/src/lib/entities/src/types/passive/mod.rs b/src/lib/entities/src/types/passive/mod.rs new file mode 100644 index 000000000..8f32031c7 --- /dev/null +++ b/src/lib/entities/src/types/passive/mod.rs @@ -0,0 +1,3 @@ +pub mod pig; + +pub use pig::{PigBundle, PigData}; diff --git a/src/lib/entities/src/types/passive/pig/bundle.rs b/src/lib/entities/src/types/passive/pig/bundle.rs new file mode 100644 index 000000000..719d437f7 --- /dev/null +++ b/src/lib/entities/src/types/passive/pig/bundle.rs @@ -0,0 +1,61 @@ +use crate::components::*; +use crate::types::passive::pig::data::PigData; +use bevy_ecs::prelude::*; +use ferrumc_core::transform::grounded::OnGround; +use ferrumc_core::transform::position::Position; +use ferrumc_core::transform::rotation::Rotation; +use ferrumc_data::generated::entities::EntityType as EntityTypeData; +use uuid::Uuid; + +/// Complete bundle for spawning a pig entity +/// +/// Includes both generic components (position, health) and pig-specific data (PigData). +/// By default, pigs are marked as Persisted to be saved to the database. +#[derive(Bundle)] +pub struct PigBundle { + // Generic components (shared across all entities) + pub entity_type: EntityType, + pub entity_id: EntityId, + pub position: Position, + pub rotation: Rotation, + pub velocity: Velocity, + pub health: Health, + pub age: Age, + pub on_ground: OnGround, + pub uuid: EntityUuid, + pub last_synced_position: LastSyncedPosition, + + // Pig-specific data (implements GameEntity) + pub pig_data: PigData, + + // Persistence marker (save to database on chunk unload) + pub persisted: Persisted, +} + +/// entity UUID +#[derive(Component)] +pub struct EntityUuid(pub Uuid); + +impl PigBundle { + pub fn new(entity_id: i64, position: Position) -> Self { + Self { + // Generic components + entity_type: EntityType::Pig, + entity_id: EntityId::new(entity_id), + last_synced_position: crate::components::LastSyncedPosition::from_position(&position), + position, + rotation: Rotation::default(), + velocity: Velocity::zero(), + health: Health::new(EntityTypeData::PIG.max_health.unwrap()), // Pig max health from vanilla data (10.0) + age: Age::new(), + on_ground: OnGround(true), // Spawn on ground to prevent falling before sync + uuid: EntityUuid(Uuid::new_v4()), + + // Pig-specific data + pig_data: PigData::default(), + + // Persistence marker + persisted: Persisted, + } + } +} diff --git a/src/lib/entities/src/types/passive/pig/data.rs b/src/lib/entities/src/types/passive/pig/data.rs new file mode 100644 index 000000000..306c20773 --- /dev/null +++ b/src/lib/entities/src/types/passive/pig/data.rs @@ -0,0 +1,22 @@ +use crate::game_entity::GameEntity; +use bevy_ecs::prelude::{Commands, Component}; +use ferrumc_state::GlobalState; +use serde::{Deserialize, Serialize}; +use typename::TypeName; + +/// Pig-specific data component +#[derive(Debug, Default, Clone, Component, Serialize, Deserialize, TypeName)] +pub struct PigData { + pub saddled: bool, + pub boost_time: i32, +} + +impl GameEntity for PigData { + fn tick(&mut self, _state: &GlobalState, _commands: &mut Commands) { + // Decrease boost time if active + if self.boost_time > 0 { + self.boost_time -= 1; + } + // TODO: Pig-specific AI (wandering, pathfinding, etc.) + } +} diff --git a/src/lib/entities/src/types/passive/pig/mod.rs b/src/lib/entities/src/types/passive/pig/mod.rs new file mode 100644 index 000000000..baaaca3aa --- /dev/null +++ b/src/lib/entities/src/types/passive/pig/mod.rs @@ -0,0 +1,7 @@ +pub mod bundle; +pub mod data; +pub mod systems; + +pub use bundle::{EntityUuid, PigBundle}; +pub use data::PigData; +pub use systems::pig_tick_system; diff --git a/src/lib/entities/src/types/passive/pig/systems.rs b/src/lib/entities/src/types/passive/pig/systems.rs new file mode 100644 index 000000000..8a7fd4f6e --- /dev/null +++ b/src/lib/entities/src/types/passive/pig/systems.rs @@ -0,0 +1,62 @@ +use bevy_ecs::prelude::*; +use ferrumc_core::transform::grounded::OnGround; +use ferrumc_core::transform::position::Position; +use ferrumc_core::transform::rotation::Rotation; +use ferrumc_state::GlobalStateResource; +use rand::Rng; + +use crate::GameEntity; +use crate::collision::{BoundingBox, check_obstacle_ahead}; +use crate::components::Velocity; +use crate::types::passive::pig::PigData; + +/// System that ticks pig entities to update their AI/behavior +pub fn pig_tick_system( + mut pigs: Query<( + &mut PigData, + &mut Velocity, + &mut Rotation, + &Position, + &OnGround, + )>, + state: Res, + mut commands: Commands, +) { + for (mut pig_data, mut velocity, mut rotation, position, on_ground) in pigs.iter_mut() { + // Call the entity's tick method for entity-specific behavior + pig_data.tick(&state.0, &mut commands); + + // Basic AI: Random wandering when on ground + if on_ground.0 { + let mut rng = rand::rng(); + + // Check for obstacle first - if blocked, try to jump or change direction + if check_obstacle_ahead( + &state.0, + position, + velocity.x, + velocity.z, + &BoundingBox::PIG, + ) { + // 50% chance to try jumping over obstacle, 50% chance to turn around + if rng.random_bool(0.5) && velocity.y.abs() < 0.01 { + velocity.y = 0.42; // Jump to try to get over obstacle + } else { + // Pick a new random direction when hitting a wall + let angle = rng.random_range(0.0..std::f64::consts::TAU); + velocity.x = angle.cos() * 0.25; + velocity.z = angle.sin() * 0.25; + rotation.yaw = (-velocity.x.atan2(velocity.z).to_degrees()) as f32; + } + } else { + // Only 1% chance to change direction when not blocked (less rotation) + if rng.random_bool(0.01) { + let angle = rng.random_range(0.0..std::f64::consts::TAU); + velocity.x = angle.cos() * 0.25; + velocity.z = angle.sin() * 0.25; + rotation.yaw = (-velocity.x.atan2(velocity.z).to_degrees()) as f32; + } + } + } + } +} diff --git a/src/lib/net/src/packets/incoming/interact_entity.rs b/src/lib/net/src/packets/incoming/interact_entity.rs new file mode 100644 index 000000000..db6c6c2ca --- /dev/null +++ b/src/lib/net/src/packets/incoming/interact_entity.rs @@ -0,0 +1,46 @@ +use ferrumc_macros::{packet, NetDecode}; +use ferrumc_net_codec::net_types::var_int::VarInt; + +/// Packet sent when a player interacts with an entity +/// +/// Protocol structure (Minecraft 1.21): +/// - entity_id: VarInt - The entity being interacted with +/// - interaction_type: VarInt +/// - 0 = Interact (right-click) +/// - 1 = Attack (left-click) +/// - 2 = Interact At (right-click at specific location) +/// +/// For type 1 (Attack), the only additional field is: +/// - sneaking: Boolean +/// +/// Note: Types 0 and 2 have additional fields (hand, coordinates) that would need +/// custom decoding. For now, we primarily handle Attack interactions. +#[derive(NetDecode)] +#[packet(packet_id = "interact", state = "play")] +pub struct InteractEntityPacket { + pub entity_id: VarInt, + pub interaction_type: VarInt, + pub sneaking: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InteractionType { + Interact = 0, + Attack = 1, + InteractAt = 2, +} + +impl InteractEntityPacket { + pub fn get_interaction_type(&self) -> Option { + match self.interaction_type.0 { + 0 => Some(InteractionType::Interact), + 1 => Some(InteractionType::Attack), + 2 => Some(InteractionType::InteractAt), + _ => None, + } + } + + pub fn is_attack(&self) -> bool { + self.interaction_type.0 == 1 + } +} diff --git a/src/lib/net/src/packets/incoming/mod.rs b/src/lib/net/src/packets/incoming/mod.rs index 0040f3fd5..4dd26fd9d 100644 --- a/src/lib/net/src/packets/incoming/mod.rs +++ b/src/lib/net/src/packets/incoming/mod.rs @@ -21,6 +21,7 @@ pub mod chat_message; pub mod command; pub mod command_suggestion_request; +pub mod interact_entity; pub mod swing_arm; pub mod chunk_batch_ack; diff --git a/src/lib/net/src/packets/outgoing/entity_metadata.rs b/src/lib/net/src/packets/outgoing/entity_metadata.rs index 96ad59232..a7b0f84f6 100644 --- a/src/lib/net/src/packets/outgoing/entity_metadata.rs +++ b/src/lib/net/src/packets/outgoing/entity_metadata.rs @@ -65,6 +65,15 @@ pub mod constructors { value, } } + + /// Entity with normal state (no special flags) + pub fn entity_normal_state() -> Self { + Self::new( + EntityMetadataIndexType::Byte, + EntityMetadataValue::Entity0(EntityStateMask::new()), + ) + } + /// To hide the name tag and stuff pub fn entity_sneaking_pressed() -> Self { Self::new( diff --git a/src/lib/net/src/packets/outgoing/entity_sound_effect.rs b/src/lib/net/src/packets/outgoing/entity_sound_effect.rs new file mode 100644 index 000000000..300c4f116 --- /dev/null +++ b/src/lib/net/src/packets/outgoing/entity_sound_effect.rs @@ -0,0 +1,41 @@ +use ferrumc_macros::{packet, NetEncode}; +use ferrumc_net_codec::net_types::var_int::VarInt; + +/// Entity Sound Effect packet (0x6D / 109) +/// +/// Plays a sound effect from an entity. +#[derive(NetEncode, Clone)] +#[packet(packet_id = "sound_entity", state = "play")] +pub struct EntitySoundEffectPacket { + /// The sound effect ID + pub sound_id: VarInt, + /// The sound category (e.g., hostile, neutral, player, etc.) + pub sound_category: VarInt, + /// The entity emitting the sound + pub entity_id: VarInt, + /// Volume (1.0 is 100%, can be higher) + pub volume: f32, + /// Pitch (1.0 is normal pitch, can range from 0.5 to 2.0) + pub pitch: f32, + /// Random seed for sound variations + pub seed: i64, +} + +impl EntitySoundEffectPacket { + /// Create a new entity sound effect packet + pub fn new(sound_id: i32, entity_id: i32, volume: f32, pitch: f32) -> Self { + Self { + sound_id: VarInt::new(sound_id), + sound_category: VarInt::new(5), // 5 = neutral category for entities + entity_id: VarInt::new(entity_id), + volume, + pitch, + seed: rand::random(), + } + } + + /// Create a hurt sound for an entity at normal volume and pitch + pub fn hurt(sound_id: i32, entity_id: i32) -> Self { + Self::new(sound_id, entity_id, 1.0, 1.0) + } +} diff --git a/src/lib/net/src/packets/outgoing/mod.rs b/src/lib/net/src/packets/outgoing/mod.rs index f4303c1d1..926b44338 100644 --- a/src/lib/net/src/packets/outgoing/mod.rs +++ b/src/lib/net/src/packets/outgoing/mod.rs @@ -25,6 +25,7 @@ pub mod spawn_entity; pub mod entity_animation; pub mod entity_event; pub mod entity_metadata; +pub mod entity_sound_effect; pub mod player_info_update; // --------- Movement ---------- diff --git a/src/lib/net/src/packets/outgoing/spawn_entity.rs b/src/lib/net/src/packets/outgoing/spawn_entity.rs index fb99fefd6..c8380ca46 100644 --- a/src/lib/net/src/packets/outgoing/spawn_entity.rs +++ b/src/lib/net/src/packets/outgoing/spawn_entity.rs @@ -13,7 +13,7 @@ use ferrumc_net_codec::net_types::var_int::VarInt; pub struct SpawnEntityPacket { entity_id: VarInt, entity_uuid: u128, - r#type: VarInt, + entity_type: VarInt, x: f64, y: f64, z: f64, @@ -41,7 +41,7 @@ impl SpawnEntityPacket { Ok(Self { entity_id: VarInt::new(player_identity.short_uuid), entity_uuid: player_identity.uuid.as_u128(), - r#type: VarInt::new(PLAYER_ID as i32), + entity_type: VarInt::new(PLAYER_ID as i32), x: position.x, y: position.y, z: position.z, @@ -54,4 +54,29 @@ impl SpawnEntityPacket { velocity_z: 0, }) } + + /// Create a spawn entity packet for generic entities (mobs, animals, etc.) + pub fn entity( + entity_id: i32, + entity_uuid: u128, + entity_type_id: i32, + position: &Position, + rotation: &Rotation, + ) -> Self { + Self { + entity_id: VarInt::new(entity_id), + entity_uuid, + entity_type: VarInt::new(entity_type_id), + x: position.x, + y: position.y, + z: position.z, + pitch: NetAngle::from_degrees(rotation.pitch as f64), + yaw: NetAngle::from_degrees(rotation.yaw as f64), + head_yaw: NetAngle::from_degrees(rotation.yaw as f64), + data: VarInt::new(0), + velocity_x: 0, + velocity_y: 0, + velocity_z: 0, + } + } }