feat: complete boat implementation (#1440)

* feat: implement boat placement

- add boat item handler for all boat/raft types
- raycast with fluid handling to place on water or blocks
- filter entities with can_hit predicate (vanilla parity)
- check space is empty before spawning
- decrement item unless creative mode

* feat: add boat entity with damage handling

- create BoatEntity struct implementing EntityBase
- implement can_hit() and is_collidable() for boats
- implement damage handling that drops boat item on destroy
- update boat item to spawn BoatEntity instead of generic Entity

* fix: boat damage wobble and attack sounds match vanilla

Boats now accumulate damage with wobble animation that decays over time.
Attack sounds play for all hittable entities, not just living entities.

* feat: add boat riding and vehicle movement packets

* feat: complete boat implementation matching vanilla

- add underwater check (60 ticks) and max 2 passengers limit
- add paddle animation metadata and handler
- implement proper land dismount with offset calculation
- add riding_cooldown field to entity

* fix: clippy warnings for boat implementation

* refactor: use loot tables for boat item drops

* fix: collapse nested if to satisfy clippy

* style: fix cargo fmt formatting for collapsible if

* fix: boat dismount places player correctly beside vehicle

- implement vanilla's dismount position calculation with
  getDismountHeight, canDismountInBlock, and pose fallback
  (standing → crouching → swimming)
- fix packet ordering race: use enqueue_packet for teleport so it
  arrives after CSetPassengers (send_packet_now bypassed the queue)
- pre-set awaiting_teleport before CSetPassengers to block stale
  movement packets during async dismount calculation
- send CSetPassengers directly to dismounting player, broadcast to
  others (matching vanilla's targeted packet behavior)
- use DELTA|ROT position flags for teleport (vanilla preserves
  current look direction with relative rotation)
- add riding cooldown (60 ticks) to prevent immediate re-mount
- reset sneaking state after player dismount
- fix water detection to check specifically for water/flowing_water
  fluids (Fluid::from_state_id returns EMPTY for air blocks)
- revert broken loot table refactor for boat item drops (boats use
  hardcoded entity-to-item mapping, not loot tables)
- add get_dismount_height helper on World matching vanilla's
  BlockView.getDismountHeight()
- ignore movement packets while awaiting teleport confirmation in
  handle_position and handle_position_rotation

* style: fix cargo fmt formatting

* refactor: use loot tables for boat item drops

add loot table entries to entities.json for all 20 boat/raft types
and use the existing loot table system instead of hardcoded mapping.

* fix: resolve rebase conflicts and clippy warnings

* fix: regenerate codegen to include boat loot tables
This commit is contained in:
Richard Marshall
2026-02-17 19:47:49 +00:00
committed by GitHub
parent 1c4ea3a1d5
commit b77d9c1b4d
18 changed files with 1487 additions and 84 deletions

View File

@@ -17,6 +17,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:acacia_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"acacia_chest_boat": {
@@ -37,6 +52,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:acacia_chest_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"allay": {
@@ -197,6 +227,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:bamboo_chest_raft"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"bamboo_raft": {
@@ -217,6 +262,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:bamboo_raft"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"bat": {
@@ -287,6 +347,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:birch_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"birch_chest_boat": {
@@ -307,6 +382,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:birch_chest_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"blaze": {
@@ -840,6 +930,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:cherry_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"cherry_chest_boat": {
@@ -860,6 +965,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:cherry_chest_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"chest_minecart": {
@@ -1407,6 +1527,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:dark_oak_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"dark_oak_chest_boat": {
@@ -1427,6 +1562,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:dark_oak_chest_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"dolphin": {
@@ -3389,6 +3539,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:jungle_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"jungle_chest_boat": {
@@ -3409,6 +3574,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:jungle_chest_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"leash_knot": {
@@ -3700,6 +3880,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:mangrove_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"mangrove_chest_boat": {
@@ -3720,6 +3915,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:mangrove_chest_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"mannequin": {
@@ -4037,6 +4247,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:oak_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"oak_chest_boat": {
@@ -4057,6 +4282,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:oak_chest_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"ocelot": {
@@ -4142,6 +4382,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:pale_oak_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"pale_oak_chest_boat": {
@@ -4162,6 +4417,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:pale_oak_chest_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"panda": {
@@ -6206,6 +6476,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:spruce_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"spruce_chest_boat": {
@@ -6226,6 +6511,21 @@
"spawn_restriction": {
"location": "UNRESTRICTED",
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
},
"loot_table": {
"type": "minecraft:entity",
"pools": [
{
"entries": [
{
"type": "minecraft:item",
"name": "minecraft:spruce_chest_boat"
}
],
"rolls": 1.0,
"bonus_rolls": 0.0
}
]
}
},
"squid": {
@@ -8013,4 +8313,4 @@
"heightmap": "MOTION_BLOCKING_NO_LEAVES"
}
}
}
}

View File

@@ -153,7 +153,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:acacia_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -173,7 +189,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:acacia_chest_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -329,7 +361,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:bamboo_chest_raft",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -349,7 +397,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:bamboo_raft",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -417,7 +481,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:birch_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -437,7 +517,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:birch_chest_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -913,7 +1009,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:cherry_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -933,7 +1045,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:cherry_chest_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -1346,7 +1474,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:dark_oak_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -1366,7 +1510,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:dark_oak_chest_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -2976,7 +3136,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:jungle_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -2996,7 +3172,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:jungle_chest_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -3242,7 +3434,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:mangrove_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -3262,7 +3470,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:mangrove_chest_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -3521,7 +3745,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:oak_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -3541,7 +3781,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:oak_chest_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -3625,7 +3881,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:pale_oak_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -3645,7 +3917,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:pale_oak_chest_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -5110,7 +5398,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:spruce_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {
@@ -5130,7 +5434,23 @@ impl EntityType {
fire_immune: false,
category: &MobCategory::MISC,
can_spawn_far_from_player: true,
loot_table: None,
loot_table: Some(LootTable {
r#type: LootTableType::Entity,
random_sequence: None,
pools: Some(&[LootPool {
entries: &[LootPoolEntry {
content: LootPoolEntryTypes::Item(ItemEntry {
name: "minecraft:spruce_chest_boat",
}),
conditions: None,
functions: None,
}],
rolls: LootNumberProviderTypes::Constant(1f32),
bonus_rolls: 0f32,
conditions: None,
functions: None,
}]),
}),
dimension: [1.375f32, 0.5625f32],
eye_height: 0.5625f32,
spawn_restriction: SpawnRestriction {

View File

@@ -639,31 +639,14 @@ impl Fluid {
#[allow(unreachable_patterns, clippy::match_overlapping_arm)]
pub const fn from_state_id(id: u16) -> Option<&'static Self> {
match id {
0u16..=0u16 => Some(&Fluid::EMPTY),
86u16..=86u16 => Some(&Fluid::WATER),
86u16..=101u16 => Some(&Fluid::FLOWING_WATER),
102u16..=102u16 => Some(&Fluid::LAVA),
102u16..=117u16 => Some(&Fluid::FLOWING_LAVA),
0..=0 => Some(&Fluid::EMPTY),
86..=86 => Some(&Fluid::WATER),
102..=102 => Some(&Fluid::LAVA),
86..=101 => Some(&Fluid::FLOWING_WATER),
102..=117 => Some(&Fluid::FLOWING_LAVA),
_ => None,
}
}
pub fn same_fluid_type(a: u16, b: u16) -> bool {
a == b
|| (a == 1 && b == 2)
|| (a == 2 && b == 1)
|| (a == 3 && b == 4)
|| (a == 4 && b == 3)
}
pub fn matches_type(&self, other: &Fluid) -> bool {
Self::same_fluid_type(self.id, other.id)
}
pub fn to_flowing(&self) -> &'static Fluid {
match self.id {
2 => &Fluid::FLOWING_WATER,
4 => &Fluid::FLOWING_LAVA,
_ => Fluid::from_id(self.id).unwrap_or(&Fluid::EMPTY),
}
}
pub fn ident_to_fluid_id(name: &str) -> Option<u8> {
match name {
"empty" => Some(0),
@@ -714,6 +697,23 @@ impl Fluid {
_ => panic!("Invalid props"),
}
}
pub fn same_fluid_type(a: u16, b: u16) -> bool {
a == b
|| (a == 1 && b == 2)
|| (a == 2 && b == 1)
|| (a == 3 && b == 4)
|| (a == 4 && b == 3)
}
pub fn matches_type(&self, other: &Fluid) -> bool {
Self::same_fluid_type(self.id, other.id)
}
pub fn to_flowing(&self) -> &'static Fluid {
match self.id {
2 => &Fluid::FLOWING_WATER,
4 => &Fluid::FLOWING_LAVA,
_ => Fluid::from_id(self.id).unwrap_or(&Fluid::EMPTY),
}
}
pub fn is_source(&self, state_id: u16) -> bool {
let idx = (state_id as usize) % self.states.len();
self.states[idx].is_source

View File

@@ -3,57 +3,57 @@ use pumpkin_util::version::MinecraftVersion;
pub type Tag = (&'static [&'static str], &'static [u16]);
#[derive(Eq, PartialEq, Hash, Debug, Clone, Copy)]
pub enum RegistryKey {
BannerPattern,
DamageType,
Item,
Instrument,
WorldgenBiome,
Fluid,
PaintingVariant,
Timeline,
GameEvent,
WorldgenBiome,
Instrument,
Block,
PointOfInterestType,
EntityType,
BannerPattern,
Item,
GameEvent,
Timeline,
Dialog,
Enchantment,
Fluid,
PointOfInterestType,
DamageType,
EntityType,
}
impl RegistryKey {
pub fn from_string(s: &str) -> Option<Self> {
match s {
"banner_pattern" => Some(Self::BannerPattern),
"damage_type" => Some(Self::DamageType),
"item" => Some(Self::Item),
"instrument" => Some(Self::Instrument),
"worldgen/biome" => Some(Self::WorldgenBiome),
"fluid" => Some(Self::Fluid),
"painting_variant" => Some(Self::PaintingVariant),
"timeline" => Some(Self::Timeline),
"game_event" => Some(Self::GameEvent),
"worldgen/biome" => Some(Self::WorldgenBiome),
"instrument" => Some(Self::Instrument),
"block" => Some(Self::Block),
"point_of_interest_type" => Some(Self::PointOfInterestType),
"entity_type" => Some(Self::EntityType),
"banner_pattern" => Some(Self::BannerPattern),
"item" => Some(Self::Item),
"game_event" => Some(Self::GameEvent),
"timeline" => Some(Self::Timeline),
"dialog" => Some(Self::Dialog),
"enchantment" => Some(Self::Enchantment),
"fluid" => Some(Self::Fluid),
"point_of_interest_type" => Some(Self::PointOfInterestType),
"damage_type" => Some(Self::DamageType),
"entity_type" => Some(Self::EntityType),
_ => None,
}
}
pub fn identifier_string(&self) -> &str {
match self {
Self::BannerPattern => "banner_pattern",
Self::DamageType => "damage_type",
Self::Item => "item",
Self::Instrument => "instrument",
Self::WorldgenBiome => "worldgen/biome",
Self::Fluid => "fluid",
Self::PaintingVariant => "painting_variant",
Self::Timeline => "timeline",
Self::GameEvent => "game_event",
Self::WorldgenBiome => "worldgen/biome",
Self::Instrument => "instrument",
Self::Block => "block",
Self::PointOfInterestType => "point_of_interest_type",
Self::EntityType => "entity_type",
Self::BannerPattern => "banner_pattern",
Self::Item => "item",
Self::GameEvent => "game_event",
Self::Timeline => "timeline",
Self::Dialog => "dialog",
Self::Enchantment => "enchantment",
Self::Fluid => "fluid",
Self::PointOfInterestType => "point_of_interest_type",
Self::DamageType => "damage_type",
Self::EntityType => "entity_type",
}
}
}

View File

@@ -67,6 +67,7 @@ mod set_equipment;
mod set_experience;
mod set_health;
mod set_held_item;
mod set_passengers;
mod set_player_inventory;
mod set_time;
mod set_title;
@@ -160,6 +161,7 @@ pub use set_equipment::*;
pub use set_experience::*;
pub use set_health::*;
pub use set_held_item::*;
pub use set_passengers::*;
pub use set_player_inventory::*;
pub use set_time::*;
pub use set_title::*;

View File

@@ -0,0 +1,22 @@
use pumpkin_data::packet::clientbound::PLAY_SET_PASSENGERS;
use pumpkin_macros::java_packet;
use serde::Serialize;
use crate::VarInt;
#[derive(Serialize)]
#[java_packet(PLAY_SET_PASSENGERS)]
pub struct CSetPassengers<'a> {
pub entity_id: VarInt,
pub passengers: &'a [VarInt],
}
impl<'a> CSetPassengers<'a> {
#[must_use]
pub const fn new(entity_id: VarInt, passengers: &'a [VarInt]) -> Self {
Self {
entity_id,
passengers,
}
}
}

View File

@@ -13,6 +13,8 @@ mod cookie_response;
mod custom_payload;
mod interact;
mod keep_alive;
mod move_vehicle;
mod paddle_boat;
mod pick_item;
mod ping_request;
mod player_abilities;
@@ -48,6 +50,8 @@ pub use cookie_response::*;
pub use custom_payload::*;
pub use interact::*;
pub use keep_alive::*;
pub use move_vehicle::*;
pub use paddle_boat::*;
pub use pick_item::*;
pub use ping_request::*;
pub use player_abilities::*;

View File

@@ -0,0 +1,13 @@
use pumpkin_data::packet::serverbound::PLAY_MOVE_VEHICLE;
use pumpkin_macros::java_packet;
use serde::Deserialize;
#[derive(Deserialize)]
#[java_packet(PLAY_MOVE_VEHICLE)]
pub struct SMoveVehicle {
pub x: f64,
pub y: f64,
pub z: f64,
pub yaw: f32,
pub pitch: f32,
}

View File

@@ -0,0 +1,10 @@
use pumpkin_data::packet::serverbound::PLAY_PADDLE_BOAT;
use pumpkin_macros::java_packet;
use serde::Deserialize;
#[derive(Deserialize)]
#[java_packet(PLAY_PADDLE_BOAT)]
pub struct SPaddleBoat {
pub left_paddle: bool,
pub right_paddle: bool,
}

View File

@@ -27,10 +27,11 @@ use pumpkin_data::{
use pumpkin_nbt::{compound::NbtCompound, tag::NbtTag};
use pumpkin_protocol::java::client::play::{CUpdateEntityPos, CUpdateEntityPosRot};
use pumpkin_protocol::{
PositionFlag,
codec::var_int::VarInt,
java::client::play::{
CEntityPositionSync, CEntityVelocity, CHeadRot, CSetEntityMetadata, CSpawnEntity,
CUpdateEntityRot, Metadata,
CEntityPositionSync, CEntityVelocity, CHeadRot, CPlayerPosition, CSetEntityMetadata,
CSetPassengers, CSpawnEntity, CUpdateEntityRot, Metadata,
},
};
use pumpkin_util::math::vector3::Axis;
@@ -76,6 +77,7 @@ pub mod projectile;
pub mod projectile_deflection;
pub mod tnt;
pub mod r#type;
pub mod vehicle;
mod combat;
pub mod predicate;
@@ -234,6 +236,10 @@ pub trait EntityBase: Send + Sync + NBTStorage {
Box::pin(async {})
}
fn set_paddle_state(&self, _left: bool, _right: bool) -> EntityBaseFuture<'_, ()> {
Box::pin(async {})
}
fn get_entity(&self) -> &Entity;
fn get_living_entity(&self) -> Option<&LivingEntity>;
@@ -401,6 +407,8 @@ pub struct Entity {
pub passengers: Mutex<Vec<Arc<dyn EntityBase>>>,
/// The vehicle that entity is in
pub vehicle: Mutex<Option<Arc<dyn EntityBase>>>,
/// Cooldown before entity can mount again after dismounting
pub riding_cooldown: AtomicI32,
/// The age of the entity in ticks. Negative values indicate a baby.
pub age: AtomicI32,
@@ -497,6 +505,7 @@ impl Entity {
removal_reason: AtomicCell::new(None),
passengers: Mutex::new(Vec::new()),
vehicle: Mutex::new(None),
riding_cooldown: AtomicI32::new(0),
age: AtomicI32::new(0),
portal_cooldown: AtomicU32::new(0),
portal_manager: Mutex::new(None),
@@ -2102,6 +2111,211 @@ impl Entity {
vehicle.is_some()
}
pub async fn add_passenger(
&self,
vehicle: Arc<dyn EntityBase>,
passenger: Arc<dyn EntityBase>,
) {
let passenger_entity = passenger.get_entity();
*passenger_entity.vehicle.lock().await = Some(vehicle);
let mut passengers = self.passengers.lock().await;
passengers.push(passenger);
let passenger_ids: Vec<VarInt> = passengers
.iter()
.map(|p| VarInt(p.get_entity().entity_id))
.collect();
let world = self.world.load();
world
.broadcast_packet_all(&CSetPassengers::new(VarInt(self.entity_id), &passenger_ids))
.await;
}
#[allow(clippy::too_many_lines)]
pub async fn remove_passenger(&self, passenger_id: i32) {
let mut passengers = self.passengers.lock().await;
let removed_passenger = if let Some(idx) = passengers
.iter()
.position(|p| p.get_entity().entity_id == passenger_id)
{
let passenger = passengers.remove(idx);
*passenger.get_entity().vehicle.lock().await = None;
Some(passenger)
} else {
None
};
let passenger_ids: Vec<VarInt> = passengers
.iter()
.map(|p| VarInt(p.get_entity().entity_id))
.collect();
drop(passengers);
if let Some(passenger) = removed_passenger {
let vehicle_box = self.bounding_box.load();
let passenger_entity = passenger.get_entity();
let passenger_yaw = passenger_entity.yaw.load();
let passenger_width = passenger_entity.entity_dimension.load().width as f64;
let vehicle_width = self.entity_dimension.load().width as f64;
// Pre-allocate teleport ID and block movement packets BEFORE sending
// CSetPassengers. This prevents a race condition where the client receives
// the dismount packet, sends stale position packets from the old riding
// position, and the server processes them before the teleport arrives.
let teleport_id = if let Some(player) = passenger.get_player() {
let id = player
.teleport_id_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
// Use fallback position as placeholder — updated below with real position
let placeholder =
Vector3::new(self.pos.load().x, vehicle_box.max.y, self.pos.load().z);
*player.awaiting_teleport.lock().await = Some((id.into(), placeholder));
Some(id)
} else {
None
};
// Vanilla: ridingCooldown = 60 (prevents immediate re-mount)
passenger_entity.riding_cooldown.store(60, Relaxed);
// TODO: world.emitGameEvent(passenger, GameEvent.ENTITY_DISMOUNT, vehicle.pos)
// Now send CSetPassengers — client movement is already blocked.
// Vanilla sends this directly to the dismounting player's connection,
// then broadcasts to other players separately.
let world = self.world.load();
let passengers_packet = CSetPassengers::new(VarInt(self.entity_id), &passenger_ids);
if let Some(player) = passenger.get_player() {
player.client.enqueue_packet(&passengers_packet).await;
world
.broadcast_packet_except(&[player.gameprofile.id], &passengers_packet)
.await;
} else {
world.broadcast_packet_all(&passengers_packet).await;
}
// Calculate dismount offset (vanilla getPassengerDismountOffset)
let offset_dist =
(vehicle_width * std::f64::consts::SQRT_2 + passenger_width + 0.00001) / 2.0;
let yaw_rad = (-passenger_yaw).to_radians();
let sin_yaw = f64::from(yaw_rad.sin());
let cos_yaw = f64::from(yaw_rad.cos());
let max_component = sin_yaw.abs().max(cos_yaw.abs());
let offset_x = sin_yaw * offset_dist / max_component;
let offset_z = cos_yaw * offset_dist / max_component;
let target_x = self.pos.load().x + offset_x;
let target_z = self.pos.load().z + offset_z;
let target_block_y = vehicle_box.max.y.floor() as i32;
let block_pos = BlockPos(Vector3::new(
target_x.floor() as i32,
target_block_y,
target_z.floor() as i32,
));
let below_pos = BlockPos(Vector3::new(
target_x.floor() as i32,
target_block_y - 1,
target_z.floor() as i32,
));
let below_state_id = world.get_block_state_id(&below_pos).await;
// Vanilla: isWater checks specifically for water fluid, not any fluid
let is_water = Fluid::from_state_id(below_state_id)
.is_some_and(|f| f.id == Fluid::WATER.id || f.id == Fluid::FLOWING_WATER.id);
let fallback_pos =
Vector3::new(self.pos.load().x, vehicle_box.max.y, self.pos.load().z);
let dismount_pos = if is_water {
fallback_pos
} else {
// Vanilla tries two Y levels: at vehicle top and one block below
let mut candidates = Vec::new();
for pos in [&block_pos, &below_pos] {
let height = world.get_dismount_height(pos).await;
// Vanilla: canDismountInBlock = !height.is_infinite() && height < 1.0
if height.is_finite() && height < 1.0 {
candidates.push(Vector3::new(
target_x,
f64::from(pos.0.y) + height,
target_z,
));
}
}
// Try poses: Standing, Crouching, Swimming (vanilla order)
let poses = [
EntityPose::Standing,
EntityPose::Crouching,
EntityPose::Swimming,
];
let mut found = None;
'outer: for pose in poses {
let dims = Self::get_entity_dimensions(pose);
for candidate in &candidates {
let bbox =
BoundingBox::new_from_pos(candidate.x, candidate.y, candidate.z, &dims);
if world.is_space_empty(bbox).await {
found = Some((*candidate, pose));
break 'outer;
}
}
}
if let Some((pos, pose)) = found {
if pose != EntityPose::Standing {
passenger_entity.set_pose(pose).await;
}
pos
} else {
fallback_pos
}
};
if let Some(player) = passenger.get_player() {
let id = teleport_id.unwrap();
player.living_entity.entity.set_pos(dismount_pos);
// Update awaiting_teleport with the real dismount position
*player.awaiting_teleport.lock().await = Some((id.into(), dismount_pos));
// Use enqueue_packet (not send_packet_now) so the teleport goes through
// the same packet queue as CSetPassengers, preserving send order.
// Vanilla uses DELTA | ROT flags: position absolute, delta/rotation relative.
// With rotation relative and yaw/pitch=0, the client preserves its current look.
player
.client
.enqueue_packet(&CPlayerPosition::new(
id.into(),
dismount_pos,
Vector3::new(0.0, 0.0, 0.0),
0.0,
0.0,
vec![
PositionFlag::DeltaX,
PositionFlag::DeltaY,
PositionFlag::DeltaZ,
PositionFlag::YRot,
PositionFlag::XRot,
],
))
.await;
// Vanilla: setSneaking(false) after dismount via sneak input
if passenger_entity.sneaking.load(Relaxed) {
passenger_entity.set_sneaking(false).await;
}
} else {
passenger_entity.set_pos(dismount_pos);
}
} else {
// No passenger was removed, still need to broadcast the passenger list
let world = self.world.load();
world
.broadcast_packet_all(&CSetPassengers::new(VarInt(self.entity_id), &passenger_ids))
.await;
}
}
pub async fn check_out_of_world(&self, dyn_self: &dyn EntityBase) {
if self.pos.load().y < f64::from(self.world.load().dimension.min_y) - 64.0 {
dyn_self.tick_in_void(dyn_self).await;
@@ -2257,6 +2471,12 @@ impl EntityBase for Entity {
// Tick freeze state (powder snow)
self.tick_frozen(&*caller).await;
let riding_cooldown = self.riding_cooldown.load(Ordering::Relaxed);
if riding_cooldown > 0 {
self.riding_cooldown
.store(riding_cooldown - 1, Ordering::Relaxed);
}
})
}

View File

@@ -730,9 +730,10 @@ impl Player {
return;
}
player_attack_sound(&pos, &world, attack_type).await;
if victim.get_living_entity().is_some() {
let mut knockback_strength = 1.0;
player_attack_sound(&pos, &world, attack_type).await;
match attack_type {
AttackType::Knockback => knockback_strength += 1.0,
AttackType::Sweeping => {

View File

@@ -0,0 +1,242 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use crossbeam::atomic::AtomicCell;
use crate::entity::player::Player;
use crate::entity::{Entity, EntityBase, EntityBaseFuture, NBTStorage, living::LivingEntity};
use crate::server::Server;
use crate::world::loot::{LootContextParameters, LootTableExt};
use pumpkin_data::damage::DamageType;
use pumpkin_data::meta_data_type::MetaDataType;
use pumpkin_data::tracked_data::TrackedData;
use pumpkin_protocol::codec::var_int::VarInt;
use pumpkin_protocol::java::client::play::Metadata;
use pumpkin_util::GameMode;
use pumpkin_util::math::vector3::Vector3;
use pumpkin_world::item::ItemStack;
pub struct BoatEntity {
entity: Entity,
damage_wobble_ticks: AtomicI32,
damage_wobble_side: AtomicI32,
damage_wobble_strength: AtomicCell<f32>,
ticks_underwater: AtomicCell<f32>,
left_paddle_moving: AtomicBool,
right_paddle_moving: AtomicBool,
}
impl BoatEntity {
pub const fn new(entity: Entity) -> Self {
Self {
entity,
damage_wobble_ticks: AtomicI32::new(0),
damage_wobble_side: AtomicI32::new(1),
damage_wobble_strength: AtomicCell::new(0.0),
ticks_underwater: AtomicCell::new(0.0),
left_paddle_moving: AtomicBool::new(false),
right_paddle_moving: AtomicBool::new(false),
}
}
pub async fn set_paddles(&self, left: bool, right: bool) {
self.left_paddle_moving.store(left, Ordering::Relaxed);
self.right_paddle_moving.store(right, Ordering::Relaxed);
self.entity
.send_meta_data(&[
Metadata::new(
TrackedData::DATA_LEFT_PADDLE_MOVING,
MetaDataType::Boolean,
left,
),
Metadata::new(
TrackedData::DATA_RIGHT_PADDLE_MOVING,
MetaDataType::Boolean,
right,
),
])
.await;
}
async fn send_wobble_metadata(&self) {
self.entity
.send_meta_data(&[
Metadata::new(
TrackedData::DATA_DAMAGE_WOBBLE_TICKS,
MetaDataType::Integer,
VarInt(self.damage_wobble_ticks.load(Ordering::Relaxed)),
),
Metadata::new(
TrackedData::DATA_DAMAGE_WOBBLE_SIDE,
MetaDataType::Integer,
VarInt(self.damage_wobble_side.load(Ordering::Relaxed)),
),
])
.await;
self.entity
.send_meta_data(&[Metadata::new(
TrackedData::DATA_DAMAGE_WOBBLE_STRENGTH,
MetaDataType::Float,
self.damage_wobble_strength.load(),
)])
.await;
}
async fn kill_and_drop_self(&self) {
let world = self.entity.world.load();
let entity_drops = world.level_info.load().game_rules.entity_drops;
if entity_drops && let Some(loot_table) = &self.entity.entity_type.loot_table {
let pos = self.entity.block_pos.load();
let params = LootContextParameters::default();
for stack in loot_table.get_loot(params) {
world.drop_stack(&pos, stack).await;
}
}
self.entity.remove().await;
}
}
impl NBTStorage for BoatEntity {}
impl EntityBase for BoatEntity {
fn get_entity(&self) -> &Entity {
&self.entity
}
fn get_living_entity(&self) -> Option<&LivingEntity> {
None
}
fn tick<'a>(
&'a self,
_caller: Arc<dyn EntityBase>,
_server: &'a Server,
) -> EntityBaseFuture<'a, ()> {
Box::pin(async move {
let ticks = self.damage_wobble_ticks.load(Ordering::Relaxed);
if ticks > 0 {
self.damage_wobble_ticks.store(ticks - 1, Ordering::Relaxed);
}
let strength = self.damage_wobble_strength.load();
if strength > 0.0 {
self.damage_wobble_strength.store(strength - 1.0);
}
let underwater = self.ticks_underwater.load();
if self.entity.touching_water.load(Ordering::Relaxed) {
self.ticks_underwater.store((underwater + 1.0).min(60.0));
} else if underwater > 0.0 {
self.ticks_underwater.store((underwater - 1.0).max(0.0));
}
})
}
fn init_data_tracker(&self) -> EntityBaseFuture<'_, ()> {
Box::pin(async move {
self.send_wobble_metadata().await;
})
}
fn can_hit(&self) -> bool {
self.entity.is_alive()
}
fn is_collidable(&self, _entity: Option<Box<dyn EntityBase>>) -> bool {
true
}
fn damage_with_context<'a>(
&'a self,
_caller: &'a dyn EntityBase,
amount: f32,
_damage_type: DamageType,
_position: Option<Vector3<f64>>,
source: Option<&'a dyn EntityBase>,
_cause: Option<&'a dyn EntityBase>,
) -> EntityBaseFuture<'a, bool> {
Box::pin(async move {
if !self.entity.is_alive() {
return true;
}
let current_side = self.damage_wobble_side.load(Ordering::Relaxed);
self.damage_wobble_side
.store(-current_side, Ordering::Relaxed);
self.damage_wobble_ticks.store(10, Ordering::Relaxed);
self.entity.velocity_dirty.store(true, Ordering::SeqCst);
let current_strength = self.damage_wobble_strength.load();
let new_strength = current_strength + amount * 10.0;
self.damage_wobble_strength.store(new_strength);
self.send_wobble_metadata().await;
let is_creative = source
.and_then(|s| s.get_player())
.is_some_and(|p| p.gamemode.load() == GameMode::Creative);
if is_creative || new_strength > 40.0 {
if is_creative {
self.entity.remove().await;
} else {
self.kill_and_drop_self().await;
}
}
true
})
}
fn interact<'a>(
&'a self,
player: &'a Player,
_item_stack: &'a mut ItemStack,
) -> EntityBaseFuture<'a, bool> {
Box::pin(async move {
if player.living_entity.entity.sneaking.load(Ordering::Relaxed) {
return false;
}
if self.ticks_underwater.load() >= 60.0 {
return false;
}
if self.entity.passengers.lock().await.len() >= 2 {
return false;
}
if player.living_entity.entity.has_vehicle().await {
return false;
}
let world = self.entity.world.load();
let Some(vehicle) = world.get_entity_by_id(self.entity.entity_id) else {
return false;
};
let Some(passenger) = world.get_player_by_id(player.entity_id()) else {
return false;
};
self.entity
.add_passenger(vehicle, passenger as Arc<dyn EntityBase>)
.await;
true
})
}
fn set_paddle_state(&self, left: bool, right: bool) -> EntityBaseFuture<'_, ()> {
Box::pin(async move {
self.set_paddles(left, right).await;
})
}
fn as_nbt_storage(&self) -> &dyn NBTStorage {
self
}
}

View File

@@ -0,0 +1 @@
pub mod boat;

View File

@@ -0,0 +1,193 @@
use std::pin::Pin;
use std::sync::Arc;
use crate::entity::Entity;
use crate::entity::player::Player;
use crate::entity::vehicle::boat::BoatEntity;
use crate::item::{ItemBehaviour, ItemMetadata};
use pumpkin_data::Block;
use pumpkin_data::entity::EntityType;
use pumpkin_data::fluid::Fluid;
use pumpkin_data::item::Item;
use pumpkin_util::math::boundingbox::{BoundingBox, EntityDimensions};
use pumpkin_util::math::position::BlockPos;
use pumpkin_util::math::vector3::Vector3;
use crate::world::World;
pub struct BoatItem;
impl BoatItem {
/// Maps boat item to corresponding entity type
fn item_to_entity(item: &Item) -> &'static EntityType {
match item.id {
val if val == Item::OAK_BOAT.id => &EntityType::OAK_BOAT,
val if val == Item::OAK_CHEST_BOAT.id => &EntityType::OAK_CHEST_BOAT,
val if val == Item::SPRUCE_BOAT.id => &EntityType::SPRUCE_BOAT,
val if val == Item::SPRUCE_CHEST_BOAT.id => &EntityType::SPRUCE_CHEST_BOAT,
val if val == Item::BIRCH_BOAT.id => &EntityType::BIRCH_BOAT,
val if val == Item::BIRCH_CHEST_BOAT.id => &EntityType::BIRCH_CHEST_BOAT,
val if val == Item::JUNGLE_BOAT.id => &EntityType::JUNGLE_BOAT,
val if val == Item::JUNGLE_CHEST_BOAT.id => &EntityType::JUNGLE_CHEST_BOAT,
val if val == Item::ACACIA_BOAT.id => &EntityType::ACACIA_BOAT,
val if val == Item::ACACIA_CHEST_BOAT.id => &EntityType::ACACIA_CHEST_BOAT,
val if val == Item::DARK_OAK_BOAT.id => &EntityType::DARK_OAK_BOAT,
val if val == Item::DARK_OAK_CHEST_BOAT.id => &EntityType::DARK_OAK_CHEST_BOAT,
val if val == Item::MANGROVE_BOAT.id => &EntityType::MANGROVE_BOAT,
val if val == Item::MANGROVE_CHEST_BOAT.id => &EntityType::MANGROVE_CHEST_BOAT,
val if val == Item::CHERRY_BOAT.id => &EntityType::CHERRY_BOAT,
val if val == Item::CHERRY_CHEST_BOAT.id => &EntityType::CHERRY_CHEST_BOAT,
val if val == Item::PALE_OAK_BOAT.id => &EntityType::PALE_OAK_BOAT,
val if val == Item::PALE_OAK_CHEST_BOAT.id => &EntityType::PALE_OAK_CHEST_BOAT,
val if val == Item::BAMBOO_RAFT.id => &EntityType::BAMBOO_RAFT,
val if val == Item::BAMBOO_CHEST_RAFT.id => &EntityType::BAMBOO_CHEST_RAFT,
_ => unreachable!(),
}
}
/// Gets entity dimensions for the boat type
const fn get_entity_dimensions(entity_type: &EntityType) -> EntityDimensions {
EntityDimensions::new(
entity_type.dimension[0],
entity_type.dimension[1],
entity_type.eye_height,
)
}
}
impl ItemMetadata for BoatItem {
fn ids() -> Box<[u16]> {
[
Item::OAK_BOAT.id,
Item::OAK_CHEST_BOAT.id,
Item::SPRUCE_BOAT.id,
Item::SPRUCE_CHEST_BOAT.id,
Item::BIRCH_BOAT.id,
Item::BIRCH_CHEST_BOAT.id,
Item::JUNGLE_BOAT.id,
Item::JUNGLE_CHEST_BOAT.id,
Item::ACACIA_BOAT.id,
Item::ACACIA_CHEST_BOAT.id,
Item::DARK_OAK_BOAT.id,
Item::DARK_OAK_CHEST_BOAT.id,
Item::MANGROVE_BOAT.id,
Item::MANGROVE_CHEST_BOAT.id,
Item::CHERRY_BOAT.id,
Item::CHERRY_CHEST_BOAT.id,
Item::PALE_OAK_BOAT.id,
Item::PALE_OAK_CHEST_BOAT.id,
Item::BAMBOO_RAFT.id,
Item::BAMBOO_CHEST_RAFT.id,
]
.into()
}
}
impl ItemBehaviour for BoatItem {
/// Vanilla: `BoatItem.use()` - raycasts to find placement position
fn normal_use<'a>(
&'a self,
item: &'a Item,
player: &'a Player,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
Box::pin(async move {
let world = player.world();
let (start_pos, end_pos) = self.get_start_and_end_pos(player);
// Vanilla: raycast with FluidHandling.ANY - stops on water/lava surface or solid blocks
let checker = async |pos: &BlockPos, world_inner: &Arc<World>| {
let state_id = world_inner.get_block_state_id(pos).await;
// Air doesn't stop the raycast
if state_id == Block::AIR.id {
return false;
}
// Check if it's a fluid - stop on any fluid (FluidHandling.ANY in vanilla)
if Fluid::from_state_id(state_id).is_some() {
return true;
}
// Stop on solid blocks
true
};
let Some((hit_pos, _direction)) = world.raycast(start_pos, end_pos, checker).await
else {
return;
};
// Calculate hit position - center of the block top or water surface
// TODO: Vanilla uses exact raycast intersection point (hitResult.getPos()),
// Pumpkin's raycast only returns block positions.
let hit_vec = Vector3::new(
f64::from(hit_pos.0.x) + 0.5,
f64::from(hit_pos.0.y) + 1.0,
f64::from(hit_pos.0.z) + 0.5,
);
// Vanilla: Check for entities in the path that would block placement
// Get player's rotation vector stretched by 5.0 and expanded by 1.0
let (yaw, pitch) = player.rotation();
let rotation_vec =
Vector3::rotation_vector(f64::from(pitch), f64::from(yaw)).multiply(5.0, 5.0, 5.0);
let player_entity = player.living_entity.entity.bounding_box.load();
let search_box = player_entity.stretch(rotation_vec).expand_all(1.0);
let entities = world.get_entities_at_box(&search_box);
let player_eye_pos = player.eye_position();
for entity in &entities {
// Vanilla: EntityPredicates.CAN_HIT = !isSpectator() && canHit()
if entity.is_spectator() || !entity.can_hit() {
continue;
}
// Vanilla: expand by getTargetingMargin() (0.0 for most entities)
let entity_box = entity.get_entity().bounding_box.load();
// Check if entity's bounding box contains the player's eye position
if entity_box.intersects(&BoundingBox::new(player_eye_pos, player_eye_pos)) {
// Entity is blocking the line of sight at eye position
return;
}
}
// Create the boat entity
let entity_type = Self::item_to_entity(item);
let dimensions = Self::get_entity_dimensions(entity_type);
let boat_box = BoundingBox::new_from_pos(hit_vec.x, hit_vec.y, hit_vec.z, &dimensions);
// Vanilla: if (!world.isSpaceEmpty(lv7, lv7.getBoundingBox())) return FAIL
if !world.is_space_empty(boat_box).await {
return;
}
// Check no entities occupy the space
if !world.get_entities_at_box(&boat_box).is_empty() {
return;
}
// Create and spawn the boat
let entity = Entity::new(world.clone(), hit_vec, entity_type);
// Set yaw to player's yaw
let (player_yaw, _) = player.rotation();
entity.set_rotation(player_yaw, 0.0);
let boat_entity = Arc::new(BoatEntity::new(entity));
world.spawn_entity(boat_entity).await;
// Decrement item unless in creative mode
let held_item = player.inventory.held_item();
let mut stack = held_item.lock().await;
stack.decrement_unless_creative(player.gamemode.load(), 1);
// TODO: world.emitGameEvent(user, GameEvent.ENTITY_PLACE, hitResult.getPos())
// TODO: user.incrementStat(Stats.USED.getOrCreateStat(this))
})
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@@ -1,5 +1,6 @@
pub mod armor_stand;
pub mod axe;
pub mod boat;
pub mod bucket;
pub mod dye;
pub mod egg;
@@ -22,6 +23,7 @@ pub mod trident;
pub mod wind_charge;
use crate::item::items::armor_stand::ArmorStandItem;
use crate::item::items::boat::BoatItem;
use crate::item::items::end_crystal::EndCrystalItem;
use crate::item::items::firework_rocket::FireworkRocketItem;
use crate::item::items::minecart::MinecartItem;
@@ -76,6 +78,7 @@ pub fn default_registry() -> Arc<ItemRegistry> {
manager.register(GlowingInkSacItem);
manager.register(ArmorStandItem);
manager.register(WindChargeItem);
manager.register(BoatItem);
Arc::new(manager)
}

View File

@@ -8,10 +8,11 @@ use pumpkin_data::packet::CURRENT_MC_PROTOCOL;
use pumpkin_protocol::java::server::play::{
SChangeGameMode, SChatCommand, SChatMessage, SChunkBatch, SClickSlot, SClientCommand,
SClientInformationPlay, SClientTickEnd, SCloseContainer, SCommandSuggestion, SConfirmTeleport,
SCookieResponse as SPCookieResponse, SCustomPayload, SInteract, SKeepAlive, SPickItemFromBlock,
SPlayPingRequest, SPlayerAbilities, SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerLoaded,
SPlayerPosition, SPlayerPositionRotation, SPlayerRotation, SPlayerSession, SSetCommandBlock,
SSetCreativeSlot, SSetHeldItem, SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn,
SCookieResponse as SPCookieResponse, SCustomPayload, SInteract, SKeepAlive, SMoveVehicle,
SPaddleBoat, SPickItemFromBlock, SPlayPingRequest, SPlayerAbilities, SPlayerAction,
SPlayerCommand, SPlayerInput, SPlayerLoaded, SPlayerPosition, SPlayerPositionRotation,
SPlayerRotation, SPlayerSession, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem,
SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn,
};
use pumpkin_protocol::packet::MultiVersionJavaPacket;
use pumpkin_protocol::{
@@ -687,6 +688,14 @@ impl JavaClient {
self.handle_player_input(player, SPlayerInput::read(payload)?)
.await;
}
id if id == SMoveVehicle::PACKET_ID => {
self.handle_move_vehicle(player, SMoveVehicle::read(payload)?)
.await;
}
id if id == SPaddleBoat::PACKET_ID => {
self.handle_paddle_boat(player, SPaddleBoat::read(payload)?)
.await;
}
id if id == SInteract::PACKET_ID => {
self.handle_interact(player, SInteract::read(payload)?, server)
.await;

View File

@@ -51,10 +51,10 @@ use pumpkin_protocol::java::server::play::{
Action, ActionType, CommandBlockMode, FLAG_ON_GROUND, SChangeGameMode, SChatCommand,
SChatMessage, SChunkBatch, SClientCommand, SClientInformationPlay, SCloseContainer,
SCommandSuggestion, SConfirmTeleport, SCookieResponse as SPCookieResponse, SInteract,
SKeepAlive, SPickItemFromBlock, SPlayPingRequest, SPlayerAbilities, SPlayerAction,
SPlayerCommand, SPlayerInput, SPlayerPosition, SPlayerPositionRotation, SPlayerRotation,
SPlayerSession, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem, SSetPlayerGround, SSwingArm,
SUpdateSign, SUseItem, SUseItemOn, Status,
SKeepAlive, SMoveVehicle, SPaddleBoat, SPickItemFromBlock, SPlayPingRequest, SPlayerAbilities,
SPlayerAction, SPlayerCommand, SPlayerInput, SPlayerPosition, SPlayerPositionRotation,
SPlayerRotation, SPlayerSession, SSetCommandBlock, SSetCreativeSlot, SSetHeldItem,
SSetPlayerGround, SSwingArm, SUpdateSign, SUseItem, SUseItemOn, Status,
};
use pumpkin_util::math::boundingbox::BoundingBox;
use pumpkin_util::math::vector3::Vector3;
@@ -294,6 +294,10 @@ impl JavaClient {
if !player.has_client_loaded() {
return;
}
// Ignore movement packets while awaiting a teleport confirmation (vanilla behavior)
if player.awaiting_teleport.lock().await.is_some() {
return;
}
// y = feet Y
let position = packet.position;
if position.x.is_nan() || position.y.is_nan() || position.z.is_nan() {
@@ -402,6 +406,10 @@ impl JavaClient {
if !player.has_client_loaded() {
return;
}
// Ignore movement packets while awaiting a teleport confirmation (vanilla behavior)
if player.awaiting_teleport.lock().await.is_some() {
return;
}
// y = feet Y
let position = packet.position;
if !position.x.is_finite()
@@ -813,6 +821,35 @@ impl JavaClient {
if player.get_entity().sneaking.load(Ordering::Relaxed) != sneak {
player.get_entity().set_sneaking(sneak).await;
}
if sneak {
let vehicle = player.get_entity().vehicle.lock().await.clone();
if let Some(vehicle) = vehicle {
vehicle
.get_entity()
.remove_passenger(player.entity_id())
.await;
}
}
}
pub async fn handle_move_vehicle(&self, player: &Arc<Player>, packet: SMoveVehicle) {
let entity = player.get_entity();
let vehicle = entity.vehicle.lock().await;
if let Some(vehicle) = vehicle.as_ref() {
let vehicle_entity = vehicle.get_entity();
vehicle_entity.set_pos(Vector3::new(packet.x, packet.y, packet.z));
vehicle_entity.set_rotation(packet.yaw, packet.pitch);
}
}
pub async fn handle_paddle_boat(&self, player: &Arc<Player>, packet: SPaddleBoat) {
let vehicle = player.get_entity().vehicle.lock().await.clone();
if let Some(vehicle) = vehicle {
vehicle
.set_paddle_state(packet.left_paddle, packet.right_paddle)
.await;
}
}
pub async fn handle_swing_arm(&self, player: &Arc<Player>, swing_arm: SSwingArm) {

View File

@@ -1170,6 +1170,32 @@ impl World {
true
}
/// Vanilla's `BlockView.getDismountHeight()`.
/// Returns the Y surface height for dismounting at the given block position,
/// or `f64::NEG_INFINITY` if no valid surface exists.
pub async fn get_dismount_height(&self, pos: &BlockPos) -> f64 {
let state = self.get_block_state(pos).await;
let max_y = state
.get_block_collision_shapes()
.map(|s| s.max.y)
.fold(f64::NEG_INFINITY, f64::max);
if max_y != f64::NEG_INFINITY {
return max_y;
}
// No collision at pos — check block below
let below = BlockPos(Vector3::new(pos.0.x, pos.0.y - 1, pos.0.z));
let below_state = self.get_block_state(&below).await;
let below_max_y = below_state
.get_block_collision_shapes()
.map(|s| s.max.y)
.fold(f64::NEG_INFINITY, f64::max);
if below_max_y >= 1.0 {
below_max_y - 1.0
} else {
f64::NEG_INFINITY
}
}
pub async fn tick_spawning_chunk(
self: &Arc<Self>,
chunk_pos: Vector2<i32>,