Theme

alex/beta-minecraft-rust

Minecraft Beta 1.7.3 Rewrite

BRANCH main
main
SNAPSHOT Draw terrain GPU-driven from packed per-section buffers with GPU occlusion culling, add a render distance slider (2-128) with a quad/disk shape, cut chunk bookkeeping at high render distances, and fix a light underflow at y=0
DOWNLOAD
Download file

root / crates / client / src / interaction / mod.rs

mod placement;

use common::interaction::{REACH, look};
use common::inventory::{Container, ContainerKind, PlayerInventory};
use common::item::{Item, ItemStack};
use common::physics::{Aabb, BlockHit};
use common::protocol::{Connection, Packet};
use common::world::shape::{self, BlockView, SIDE_OFFSETS};
use common::world::{Block, Material};
use glam::DVec3;
use placement::Outcome;

use crate::player::LocalPlayer;
use crate::world::ClientWorld;

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Screen {
    Inventory,
    Death,
}

struct Mining {
    position: [i32; 3],
    block: Block,
    damage: f32,
    previous: f32,
    held: Option<ItemStack>,
    sound_ticks: u32,
    /// `PlayerControllerSP` spends the first tick on a block only recording it.
    fresh: bool,
}

impl Mining {
    fn at(&self, position: [i32; 3], block: Block, held: Option<ItemStack>) -> bool {
        self.position == position && self.block == block && self.held == held
    }
}

fn dig(status: u8, [x, y, z]: [i32; 3], face: u8) -> Packet {
    let y = y as u8;
    Packet::BlockDig {
        status,
        x,
        y,
        z,
        face,
    }
}

/// The swing of the hand, which the server shows to others.
fn swing(player: &mut LocalPlayer, connection: &Connection) {
    player.state.swing();
    connection.send(Packet::Animation {
        entity: player.entity.id,
        animation: 1,
    });
}

pub struct Interaction {
    /// `ItemStack.animationsToGo` of the hotbar: picked up stacks pop for five ticks.
    pub pops: [u8; 9],
    /// Connected to a remote server, which leaves predicted sounds to the client.
    pub remote: bool,
    pub inventory: PlayerInventory,
    pub container: Container,
    pub external: Vec<Option<ItemStack>>,
    pub window: i8,
    pub screen: Option<Screen>,
    pub title: String,
    pub bars: [i16; 3],
    pub cursor: [f32; 2],
    pub shift: bool,
    pub attack: bool,
    pub use_item: bool,
    pub target: Option<BlockHit>,
    /// The block under the crosshair as drawn this frame (`EntityRenderer.getMouseOver`).
    pub shown: Option<BlockHit>,
    pub rendered_item: Option<ItemStack>,
    pub equipped: [f32; 2],
    mining: Option<Mining>,
    wait: i32,
    repeat_wait: i32,
    miss_wait: i32,
    clicks: Vec<bool>,
    action: i16,
    statistics: Option<crate::stats::Shared>,
    /// A sign just placed, whose text the player writes next (`displayGUIEditSign`).
    pub placed_sign: Option<PlacedSign>,
}

/// Where a sign went and which kind it is.
#[derive(Clone, Copy)]
pub struct PlacedSign {
    pub position: [i32; 3],
    pub block: Block,
}

impl Default for Interaction {
    fn default() -> Self {
        Self {
            pops: [0; 9],
            remote: false,
            inventory: PlayerInventory::default(),
            container: Container::new(ContainerKind::Inventory),
            external: Vec::new(),
            window: 0,
            screen: None,
            title: String::new(),
            bars: [0; 3],
            cursor: [0.0; 2],
            shift: false,
            attack: false,
            use_item: false,
            target: None,
            shown: None,
            rendered_item: None,
            equipped: [0.0; 2],
            mining: None,
            wait: 0,
            repeat_wait: 0,
            miss_wait: 0,
            clicks: Vec::new(),
            action: 0,
            statistics: None,
            placed_sign: None,
        }
    }
}

impl Interaction {
    pub fn new(statistics: crate::stats::Shared, remote: bool) -> Self {
        Self {
            statistics: Some(statistics),
            remote,
            ..Default::default()
        }
    }

    fn record_drops(&self, count: i32) {
        if let Some(stats) = &self.statistics {
            stats.lock().unwrap().add(2011, count);
        }
    }

    pub fn drop_selected(&mut self, connection: &Connection) {
        if common::inventory::take(self.inventory.held_mut(), 1).is_some() {
            self.record_drops(1);
        }
        connection.send(dig(4, [0; 3], 0));
    }

    pub fn receive(&mut self, packet: &Packet) -> bool {
        match packet {
            Packet::OpenWindow {
                window,
                kind,
                title,
                slots,
            } => {
                let kind = match kind {
                    0 if matches!(*slots, 27 | 54) => ContainerKind::Chest {
                        slots: *slots as usize,
                    },
                    1 => ContainerKind::Workbench,
                    2 => ContainerKind::Furnace,
                    3 => ContainerKind::Dispenser,
                    _ => return false,
                };
                self.placed_sign = None;
                self.window = *window;
                self.container = Container::new(kind);
                self.external = vec![None; kind.external_count()];
                self.screen = Some(Screen::Inventory);
                self.title.clone_from(title);
                self.action = 0;
                self.bars = [0; 3];
            }
            Packet::CloseWindow { window } if *window == self.window => self.reset_window(),
            Packet::WindowItems { window, items } if *window == self.window => {
                for (i, &item) in items
                    .iter()
                    .take(self.container.kind.slot_count())
                    .enumerate()
                {
                    self.container
                        .set(&mut self.inventory, &mut self.external, i, item);
                }
            }
            Packet::SetSlot {
                window: -1,
                slot: -1,
                item,
            } => self.container.carried = *item,
            // Window 0 is the player's own inventory, which stays up to date behind other windows
            Packet::WindowItems { window: 0, items } => {
                let mut own = Container::new(ContainerKind::Inventory);
                for (i, &item) in items.iter().take(own.kind.slot_count()).enumerate() {
                    if (36..45).contains(&i) {
                        self.pop(i - 36, item);
                    }
                    own.set(&mut self.inventory, &mut [], i, item);
                }
            }
            Packet::SetSlot {
                window: 0,
                slot: slot @ 36..45,
                item,
            } => {
                self.pop(*slot as usize - 36, *item);
                Container::new(ContainerKind::Inventory).set(
                    &mut self.inventory,
                    &mut [],
                    *slot as usize,
                    *item,
                );
            }
            Packet::SetSlot { window, slot, item } if *window == self.window && *slot >= 0 => {
                self.container.set(
                    &mut self.inventory,
                    &mut self.external,
                    *slot as usize,
                    *item,
                );
            }
            Packet::UpdateProgressBar { window, bar, value } if *window == self.window => {
                if let Some(target) = self.bars.get_mut(*bar as usize) {
                    *target = *value;
                }
            }
            Packet::Transaction { .. } => {}
            _ => return false,
        }
        true
    }

    fn reset_window(&mut self) {
        self.window = 0;
        self.container = Container::new(ContainerKind::Inventory);
        self.external.clear();
        self.screen = None;
        self.action = 0;
    }

    pub fn close(&mut self, connection: &Connection) {
        if self.screen == Some(Screen::Inventory) {
            let drops = self.container.close();
            self.record_drops(drops.len() as i32);
            connection.send(Packet::CloseWindow {
                window: self.window,
            });
        }
        self.reset_window();
    }

    pub fn cancel(&mut self, connection: &Connection) {
        self.attack = false;
        self.use_item = false;
        self.wait = 0;
        self.repeat_wait = 0;
        self.miss_wait = 0;
        self.clicks.clear();
        self.stop_mining(connection);
    }

    /// Traces the outlined block from the camera of this frame, between game ticks.
    pub fn refresh_shown(
        &mut self,
        world: &ClientWorld,
        player: &LocalPlayer,
        entities: &crate::entities::Entities,
        partial: f32,
    ) {
        let entity = &player.entity;
        let yaw = entity.prev_yaw + (entity.yaw - entity.prev_yaw) * partial;
        let pitch = entity.prev_pitch + (entity.pitch - entity.prev_pitch) * partial;
        let eye = player.eye(partial);
        let direction = look(yaw, pitch);
        self.shown = shape::ray_trace(world, eye, eye + direction * REACH, false, false);
        let distance = self.shown.map_or(REACH, |hit| hit.point.distance(eye));
        if entities.target(eye, direction, distance).is_some() {
            self.shown = None;
        }
    }

    /// `Minecraft.clickMiddleMouseButton`: selects the first stack of the targeted block if it
    /// is in the hotbar.
    pub fn pick_block(&mut self, world: &ClientWorld, connection: &Connection) {
        let Some(hit) = self.target else {
            return;
        };
        let block = match BlockView::block(world, hit.x, hit.y, hit.z) {
            Block::GRASS => Block::DIRT,
            Block::DOUBLE_SLAB => Block::SLAB,
            Block::BEDROCK => Block::STONE,
            block => block,
        };
        let item = Item::from_block(block);
        if let Some(slot) = self
            .inventory
            .main
            .iter()
            .position(|stack| stack.is_some_and(|stack| stack.item == item))
            .filter(|&slot| slot < 9)
        {
            self.select(slot, connection);
        }
    }

    pub fn select(&mut self, slot: usize, connection: &Connection) {
        self.inventory.selected = slot % 9;
        connection.send(Packet::BlockItemSwitch {
            slot: self.inventory.selected as i16,
        });
    }

    pub fn click_slot(&mut self, index: i16, button: i8, connection: &Connection) {
        self.action = self.action.wrapping_add(1);
        let result = self.container.click(
            &mut self.inventory,
            &mut self.external,
            index,
            button,
            self.shift,
        );
        if result.dropped.is_some() {
            self.record_drops(1);
        }
        connection.send(Packet::WindowClick {
            window: self.window,
            slot: index,
            button,
            action: self.action,
            shift: self.shift,
            item: result.item,
        });
    }

    pub fn damage(&self, partial: f32) -> f32 {
        self.mining
            .as_ref()
            .map_or(0.0, |m| m.previous + (m.damage - m.previous) * partial)
    }

    /// `handleSetSlot`: a hotbar stack that appears or grows pops.
    fn pop(&mut self, slot: usize, item: Option<ItemStack>) {
        let current = self.inventory.main[slot];
        if item.is_some_and(|new| current.is_none_or(|old| old.count < new.count)) {
            self.pops[slot] = 5;
        }
    }

    pub fn tick(
        &mut self,
        world: &mut ClientWorld,
        player: &mut LocalPlayer,
        entities: &crate::entities::Entities,
        connection: &Connection,
    ) {
        for pop in &mut self.pops {
            *pop = pop.saturating_sub(1);
        }
        self.equipped[0] = self.equipped[1];
        let held = self.inventory.held();
        let same = match (held, self.rendered_item) {
            (None, None) => true,
            (Some(a), Some(b)) => {
                a.item == b.item && (a.item.is_damageable() || a.damage == b.damage)
            }
            _ => false,
        };
        self.equipped[1] += (f32::from(same) - self.equipped[1]).clamp(-0.4, 0.4);
        if self.equipped[1] < 0.1 {
            self.rendered_item = held;
        }
        let (eye, direction) = (
            player.eye(1.0),
            look(player.entity.yaw, player.entity.pitch),
        );
        self.target = shape::ray_trace(world, eye, eye + direction * REACH, false, false);
        let distance = self.target.map_or(REACH, |hit| hit.point.distance(eye));
        let target = entities.target(eye, direction, distance);
        if target.is_some() {
            self.target = None;
        }
        if self.screen.is_some() || !player.started {
            self.cancel(connection);
            return;
        }
        self.repeat_wait = (self.repeat_wait - 1).max(0);
        self.miss_wait = (self.miss_wait - 1).max(0);
        if let Some(mining) = &mut self.mining {
            mining.previous = mining.damage;
        }
        for attack in std::mem::take(&mut self.clicks) {
            self.click(attack, target, world, player, entities, connection);
            self.repeat_wait = 5;
        }
        if self.repeat_wait == 0 && (self.attack || self.use_item) {
            self.click(self.attack, target, world, player, entities, connection);
            self.repeat_wait = 5;
        }
        if !self.attack {
            self.stop_mining(connection);
            self.wait = 0;
            self.miss_wait = 0;
            return;
        }
        if self.miss_wait > 0 {
            return;
        }
        let Some(hit) = self.target else {
            self.stop_mining(connection);
            self.wait = 0;
            return;
        };
        if self.wait > 0 {
            self.wait -= 1;
            return;
        }
        let block = BlockView::block(world, hit.x, hit.y, hit.z);
        if block.is_air() {
            self.stop_mining(connection);
            return;
        }
        let position = [hit.x, hit.y, hit.z];
        let held = self.inventory.held();
        if !self
            .mining
            .as_ref()
            .is_some_and(|m| m.at(position, block, held))
        {
            self.start_mining(hit, world, player, connection);
            return;
        }
        let progress = self.inventory.mining_progress(
            block,
            player.entity.eyes_in(world, Material::Water),
            player.entity.on_ground,
        );
        if let Some(mining) = &mut self.mining
            && !std::mem::take(&mut mining.fresh)
        {
            mining.damage += progress;
            if mining.sound_ticks % 4 == 0 {
                let sound = block.sound();
                world.sounds.push((
                    sound.step().into(),
                    DVec3::new(hit.x as f64 + 0.5, hit.y as f64 + 0.5, hit.z as f64 + 0.5),
                    (sound.volume() + 1.0) / 8.0,
                    sound.pitch() * 0.5,
                ));
            }
            mining.sound_ticks += 1;
        }
        let metadata = BlockView::metadata(world, hit.x, hit.y, hit.z);
        let bounds = shape::block_bounds(world, block, hit.x, hit.y, hit.z);
        let tint = world.debris_tint(block, metadata, position);
        world
            .particles
            .hit_block(block, metadata, &hit, bounds, tint);
        if self.mining.as_ref().is_some_and(|m| m.damage >= 1.0) {
            connection.send(dig(2, position, hit.side));
            world.break_effect(position, block, metadata);
            world.set_block(hit.x, hit.y, hit.z, Block::AIR, 0);
            self.mining = None;
            self.wait = 5;
        }
    }

    pub fn attack_button(&mut self, pressed: bool) {
        self.attack = pressed;
        if pressed {
            self.clicks.push(true);
        }
    }

    pub fn use_button(&mut self, pressed: bool) {
        self.use_item = pressed;
        if pressed {
            self.clicks.push(false);
        }
    }

    fn click(
        &mut self,
        attack: bool,
        target: Option<i32>,
        world: &mut ClientWorld,
        player: &mut LocalPlayer,
        entities: &crate::entities::Entities,
        connection: &Connection,
    ) {
        if attack {
            if self.miss_wait > 0 {
                return;
            }
            swing(player, connection);
            if let Some(target) = target {
                self.stop_mining(connection);
                connection.send(Packet::UseEntity {
                    player: player.entity.id,
                    target,
                    left_click: true,
                });
            } else if let Some(hit) = self.target {
                if self.wait == 0 {
                    self.start_mining(hit, world, player, connection);
                }
            } else {
                self.stop_mining(connection);
                self.miss_wait = 10;
            }
        } else if let Some(target) = target {
            // `Minecraft.clickMouse`: the interaction, then the item's own use
            connection.send(Packet::UseEntity {
                player: player.entity.id,
                target,
                left_click: false,
            });
            if self.inventory.held().is_some() {
                connection.send(Packet::Place {
                    x: -1,
                    y: 255,
                    z: -1,
                    direction: 255,
                    item: self.inventory.held(),
                });
            }
        } else {
            if let Some(hit) = self.target
                && self
                    .inventory
                    .held()
                    .is_some_and(|stack| stack.item == Item::SIGN)
            {
                self.placed_sign = predict_sign(world, hit);
            }
            let (x, y, z, direction) = self.target.map_or((-1, 255, -1, 255), |hit| {
                (hit.x, hit.y as u8, hit.z, hit.side)
            });
            connection.send(Packet::Place {
                x,
                y,
                z,
                direction,
                item: self.inventory.held(),
            });
            // `PlayerControllerMP.sendPlaceBlock` acts at once and swings when it succeeds
            let before = self.inventory.held();
            let activated = self
                .target
                .is_some_and(|hit| activates(world, [hit.x, hit.y, hit.z]));
            let outcome = if activated {
                Outcome::Used
            } else {
                self.target
                    .zip(before)
                    .map_or(Outcome::Ignored, |(hit, stack)| {
                        placement::use_on_block(world, stack, player, &hit, |boxes| {
                            entities.obstruct(boxes)
                                || boxes.iter().any(|b| b.intersects(&player.entity.bb))
                        })
                    })
            };
            if outcome != Outcome::Ignored {
                if let Some(hit) = self.target
                    && outcome == Outcome::Spent
                {
                    predict_block(world, &mut self.inventory, player, hit, self.remote);
                }
                swing(player, connection);
                // `ItemRenderer.resetEquippedProgressOnPlace` unless the last one went
                if outcome == Outcome::Spent && before.is_some_and(|s| s.count > 1) {
                    self.equipped[1] = 0.0;
                }
            } else if let Some(stack) = before {
                if stack.item == Item::FISHING_ROD {
                    player.state.swing();
                }
                // `ItemRenderer.resetEquippedProgressOnUse`
                if placement::uses_in_air(world, stack, player) {
                    self.equipped[1] = 0.0;
                    // `ItemSnowball.onItemRightClick` on the client: one is thrown
                    if matches!(stack.item, Item::SNOWBALL | Item::EGG) {
                        common::inventory::take(self.inventory.held_mut(), 1);
                    }
                }
            }
        }
    }

    fn start_mining(
        &mut self,
        hit: BlockHit,
        world: &mut ClientWorld,
        player: &LocalPlayer,
        connection: &Connection,
    ) {
        let block = BlockView::block(world, hit.x, hit.y, hit.z);
        let position = [hit.x, hit.y, hit.z];
        let held = self.inventory.held();
        if block.is_air()
            || self
                .mining
                .as_ref()
                .is_some_and(|m| m.at(position, block, held))
        {
            return;
        }
        self.stop_mining(connection);
        connection.send(dig(0, position, hit.side));
        let progress = self.inventory.mining_progress(
            block,
            player.entity.eyes_in(world, Material::Water),
            player.entity.on_ground,
        );
        if progress >= 1.0 {
            let metadata = BlockView::metadata(world, hit.x, hit.y, hit.z);
            world.break_effect(position, block, metadata);
            world.set_block(hit.x, hit.y, hit.z, Block::AIR, 0);
        } else {
            self.mining = Some(Mining {
                position,
                block,
                damage: 0.0,
                previous: 0.0,
                held,
                sound_ticks: 0,
                fresh: !self.remote,
            });
        }
    }

    fn stop_mining(&mut self, connection: &Connection) {
        if let Some(mining) = self.mining.take() {
            connection.send(dig(1, mining.position, 0));
        }
    }
}

/// Whether `blockActivated` succeeds on the client.
fn activates(world: &ClientWorld, [x, y, z]: [i32; 3]) -> bool {
    match BlockView::block(world, x, y, z) {
        Block::JUKEBOX => BlockView::metadata(world, x, y, z) != 0,
        Block::MOVING_PISTON => BlockView::tile_entity(world, x, y, z).is_none(),
        block => matches!(
            block,
            Block::CHEST
                | Block::FURNACE
                | Block::LIT_FURNACE
                | Block::DISPENSER
                | Block::CRAFTING_TABLE
                | Block::WOODEN_DOOR
                | Block::IRON_DOOR
                | Block::TRAPDOOR
                | Block::LEVER
                | Block::BUTTON
                | Block::NOTE_BLOCK
                | Block::BED
                | Block::REPEATER
                | Block::POWERED_REPEATER
                | Block::CAKE
        ),
    }
}

/// `ItemBlock.onItemUse` on the client for plain cubes, whose placement needs no orientation
/// or support: the block appears and the stack shrinks before the server confirms.
fn predict_block(
    world: &mut ClientWorld,
    inventory: &mut PlayerInventory,
    player: &LocalPlayer,
    hit: BlockHit,
    sound: bool,
) -> bool {
    let Some(stack) = inventory.held() else {
        return false;
    };
    let Some(block) = stack.item.block().filter(|block| {
        block.is_normal_cube()
            && !block.is_container()
            && !matches!(
                *block,
                Block::PUMPKIN | Block::JACK_O_LANTERN | Block::PISTON | Block::STICKY_PISTON
            )
    }) else {
        return false;
    };
    let mut position = [hit.x, hit.y, hit.z];
    if world.block(hit.x, hit.y, hit.z) != Block::SNOW_LAYER {
        let [dx, dy, dz] = SIDE_OFFSETS[usize::from(hit.side.min(5))];
        position = [hit.x + dx, hit.y + dy, hit.z + dz];
    }
    let [x, y, z] = position;
    let current = world.block(x, y, z);
    let corner = DVec3::from_array(position.map(f64::from));
    let cell = Aabb::new(corner, corner + 1.0);
    if !(0..127).contains(&y)
        || !(current.is_air() || current.material().is_ground_cover())
        || cell.intersects(&player.entity.bb)
    {
        return false;
    }
    let metadata = stack
        .item
        .properties()
        .filter(|properties| properties.has_subtypes)
        .map_or(0, |_| stack.damage as u8);
    world.set_block(x, y, z, block, metadata);
    let step = block.sound();
    if sound {
        world.sounds.push((
            step.step().into(),
            corner + 0.5,
            (step.volume() + 1.0) / 2.0,
            step.pitch() * 0.8,
        ));
    }
    common::inventory::take(inventory.held_mut(), 1);
    true
}

/// `ItemSign.onItemUse` as the client predicts it, so it can open the sign editor.
fn predict_sign(world: &ClientWorld, hit: BlockHit) -> Option<PlacedSign> {
    if hit.side == 0 || !world.block(hit.x, hit.y, hit.z).material().is_solid() {
        return None;
    }
    let [dx, dy, dz] = SIDE_OFFSETS[usize::from(hit.side.min(5))];
    let position @ [x, y, z] = [hit.x + dx, hit.y + dy, hit.z + dz];
    let current = world.block(x, y, z);
    if !(current.is_air() || current.material().is_ground_cover()) {
        return None;
    }
    let block = if hit.side == 1 {
        Block::SIGN
    } else {
        Block::WALL_SIGN
    };
    Some(PlacedSign { position, block })
}