mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ee43da5d | ||
|
|
605fca4937 | ||
|
|
d062afe0bc | ||
|
|
390f2e0941 | ||
|
|
bc78cba5b9 | ||
|
|
ce8096db83 | ||
|
|
98c0883c8f | ||
|
|
49cf77a312 | ||
|
|
cef862471f | ||
|
|
326719539d | ||
|
|
1a9040ad79 | ||
|
|
42388d2512 | ||
|
|
2deaab2602 | ||
|
|
797482183c | ||
|
|
ca28a0edad |
No files matched your search
@@ -74,9 +74,28 @@ pub enum BlockEntity {
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Brushable {
|
||||
pub loot_table: Option<horizon_world::nbt::Tag>,
|
||||
pub loot_seed: Option<horizon_world::nbt::Tag>,
|
||||
pub item: Option<ItemStack>,
|
||||
/// Direction's legacy network id; not saved to disk by vanilla.
|
||||
pub hit_direction: Option<i32>,
|
||||
}
|
||||
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Shelf {
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub align_to_bottom: bool,
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -230,6 +230,14 @@ impl BlockEntity {
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if matches!(
|
||||
name,
|
||||
"minecraft:suspicious_sand"
|
||||
| "minecraft:suspicious_gravel"
|
||||
| "minecraft:brushable_block"
|
||||
) {
|
||||
return Some(Self::Brushable(Box::default()));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
return Some(Self::CreakingHeart(Box::default()));
|
||||
}
|
||||
@@ -292,6 +300,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:shelf" || name.ends_with("_shelf") {
|
||||
return Some(Self::Shelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
@@ -334,8 +345,10 @@ impl BlockEntity {
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -354,13 +367,15 @@ impl BlockEntity {
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -376,9 +391,11 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::Shelf(b) => &b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -402,9 +419,11 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::Shelf(b) => &mut b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
|
||||
@@ -174,6 +174,7 @@ mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod shelf;
|
||||
mod shield;
|
||||
mod shoulders;
|
||||
mod sign_edit;
|
||||
@@ -199,6 +200,7 @@ mod trading;
|
||||
mod trial_spawner;
|
||||
mod trial_visuals;
|
||||
mod trident;
|
||||
mod tutorial;
|
||||
mod tweaks;
|
||||
mod ui_control;
|
||||
mod vault;
|
||||
@@ -1683,6 +1685,8 @@ struct Game<'a> {
|
||||
attack_clicks: u32,
|
||||
hand_strength: horizon_client::attack_strength::AttackStrength,
|
||||
settings: Settings,
|
||||
tutorial: tutorial::Tutorial,
|
||||
tutorial_toasts: Vec<tutorial::HintToast>,
|
||||
/// Where the options screen returns: true = title, false = pause menu.
|
||||
options_from_title: bool,
|
||||
dragging: bool,
|
||||
@@ -2367,6 +2371,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// Inventory.add: returns the stack that did not fit.
|
||||
fn give(&mut self, mut stack: ItemStack) -> Option<ItemStack> {
|
||||
let tutorial_item = matches!(
|
||||
self.tutorial.step,
|
||||
tutorial::Step::FindTree | tutorial::Step::PunchTree | tutorial::Step::CraftPlanks
|
||||
)
|
||||
.then(|| stack.clone());
|
||||
let mut added = false;
|
||||
let max = self
|
||||
.res
|
||||
.items()
|
||||
@@ -2391,7 +2401,9 @@ impl Game<'_> {
|
||||
let moved = (max - slot.count).min(stack.count);
|
||||
slot.count += moved;
|
||||
stack.count -= moved;
|
||||
added |= moved > 0;
|
||||
if stack.count == 0 {
|
||||
self.tutorial_get_item(tutorial_item.as_ref());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -2404,11 +2416,16 @@ impl Game<'_> {
|
||||
..stack.clone()
|
||||
});
|
||||
stack.count -= moved;
|
||||
added |= moved > 0;
|
||||
if stack.count == 0 {
|
||||
self.tutorial_get_item(tutorial_item.as_ref());
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if added {
|
||||
self.tutorial_get_item(tutorial_item.as_ref());
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
/// The block's first up face (or any face) and its fixed tint, for block
|
||||
@@ -4346,6 +4363,14 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(swings) = self.use_shelf(hit) {
|
||||
let used = held.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, &used);
|
||||
if swings {
|
||||
self.swing_arm();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(swings) = self.use_chiseled_bookshelf(hit, held.as_ref()) {
|
||||
if swings {
|
||||
self.swing_arm();
|
||||
@@ -5205,6 +5230,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.tick_debug_keys()?;
|
||||
self.tick_debug_charts();
|
||||
self.tick_tutorial();
|
||||
if self.remote.is_some() {
|
||||
return self.tick_remote();
|
||||
}
|
||||
@@ -5785,6 +5811,7 @@ impl Game<'_> {
|
||||
.mouse
|
||||
.handle_accumulated_movement(time, active, grabbed, look)
|
||||
{
|
||||
self.tutorial.mouse(xo, yo);
|
||||
let mut rotation = Rotation {
|
||||
x_rot: self.pitch,
|
||||
y_rot: self.yaw,
|
||||
@@ -6387,6 +6414,7 @@ impl Game<'_> {
|
||||
self.draw_saving_indicator(&mut batch);
|
||||
// ToastManager.extractRenderState: F1 hides the toasts too.
|
||||
self.draw_toasts(&mut batch, !show_hud);
|
||||
self.draw_tutorial_toasts(&mut batch, !show_hud);
|
||||
self.draw_pack_toasts(&mut batch, !show_hud);
|
||||
if self.server_packs.prompt.is_none() {
|
||||
self.remote_hud(&mut batch);
|
||||
@@ -6448,7 +6476,9 @@ impl Game<'_> {
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_),
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
| BlockEntity::Shelf(_),
|
||||
) => {}
|
||||
None => {
|
||||
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
|
||||
@@ -6728,7 +6758,9 @@ impl Game<'_> {
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_),
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
| BlockEntity::Shelf(_),
|
||||
) => {}
|
||||
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
|
||||
None => {
|
||||
@@ -8204,6 +8236,8 @@ impl Game<'_> {
|
||||
// `CampfireRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.campfire_geometry(camera, &mut out);
|
||||
self.brushable_geometry(camera, &mut out);
|
||||
self.shelf_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
// Mobs and their projectiles (a span each).
|
||||
@@ -9175,6 +9209,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn finish_open_world(&mut self, prepared: world_loading::PreparedWorld) -> Result<()> {
|
||||
self.reset_tutorial();
|
||||
let world_loading::PreparedWorld {
|
||||
save_root,
|
||||
level,
|
||||
@@ -12422,6 +12457,9 @@ impl Game<'_> {
|
||||
Screen::Playing => {
|
||||
// key.inventory while riding a tamed mount opens its inventory.
|
||||
if !self.open_ridden_mount_menu() {
|
||||
if self.tutorial.open_inventory() {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
self.creative_screen.reopen();
|
||||
@@ -13953,6 +13991,9 @@ impl<'a> Game<'a> {
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| GameCpuResources::load(&root))?;
|
||||
let world = World::new(res.facts().air, 0);
|
||||
let tutorial = tutorial::Tutorial::new(tutorial::Step::from_option(
|
||||
settings.file.int("tutorialStep"),
|
||||
));
|
||||
let creative = options.creative;
|
||||
let inventory = vec![None; 36];
|
||||
let now = Instant::now();
|
||||
@@ -14140,6 +14181,8 @@ impl<'a> Game<'a> {
|
||||
attack_clicks: 0,
|
||||
hand_strength: Default::default(),
|
||||
settings,
|
||||
tutorial,
|
||||
tutorial_toasts: Vec::new(),
|
||||
options_from_title: false,
|
||||
dragging: false,
|
||||
avatar: Default::default(),
|
||||
|
||||
@@ -1493,7 +1493,7 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter_map(|t| t.slot)
|
||||
.chain(packs)
|
||||
.fold(0, |used, slot| used | 1 << slot)
|
||||
.fold(self.tutorial_toast_slots(), |used, slot| used | 1 << slot)
|
||||
}
|
||||
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
|
||||
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
|
||||
|
||||
@@ -801,6 +801,7 @@ impl Game<'_> {
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
self.swing_arm();
|
||||
return true;
|
||||
}
|
||||
if sneaking
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
|
||||
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
|
||||
//! entity (like a decorated pot's) until the first counted stroke rolls it.
|
||||
use super::{Game, Hit, decorated_pot, spill, view_vector};
|
||||
use super::{Game, Hit, decorated_pot, view_vector};
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light,
|
||||
containers::BlockEntity,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save,
|
||||
};
|
||||
@@ -114,6 +117,20 @@ impl Game<'_> {
|
||||
let time = self.world.game_time;
|
||||
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
|
||||
brush.hit_face.get_or_insert(face);
|
||||
let direction = match brush.hit_face.unwrap() {
|
||||
[0, -1, 0] => 0,
|
||||
[0, 1, 0] => 1,
|
||||
[0, 0, -1] => 2,
|
||||
[0, 0, 1] => 3,
|
||||
[-1, 0, 0] => 4,
|
||||
[1, 0, 0] => 5,
|
||||
_ => return false,
|
||||
};
|
||||
if let Some(BlockEntity::Brushable(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.hit_direction = Some(direction);
|
||||
}
|
||||
brush.resets_at = time + 40;
|
||||
if time < brush.cooldown_ends_at {
|
||||
self.brushing.insert(pos, brush);
|
||||
@@ -139,12 +156,18 @@ impl Game<'_> {
|
||||
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
|
||||
/// block entity's item.
|
||||
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
|
||||
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
if tag.get("LootTable").is_none() {
|
||||
if b.loot_table.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut tag = save::encode_block_entity(
|
||||
key,
|
||||
&BlockEntity::Brushable(b.clone()),
|
||||
"minecraft:brushable_block",
|
||||
);
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
decorated_pot::unpack_loot(
|
||||
&mut tag,
|
||||
@@ -153,7 +176,12 @@ impl Game<'_> {
|
||||
&mut self.rand_sequences,
|
||||
self.level.seed,
|
||||
);
|
||||
spill::put_saved_entity(&mut self.world, pos, tag);
|
||||
if let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get_mut(&key) {
|
||||
b.loot_table = tag.get("LootTable").cloned();
|
||||
b.loot_seed = tag.get("LootTableSeed").cloned();
|
||||
b.item = tag.get("item").and_then(save::item_from_nbt);
|
||||
}
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
|
||||
@@ -166,8 +194,10 @@ impl Game<'_> {
|
||||
_ => "minecraft:air",
|
||||
};
|
||||
// dropContent: the item at the middle of the block beside that face.
|
||||
let item = spill::saved_entity(&self.world, pos)
|
||||
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
|
||||
let item = match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
Some(BlockEntity::Brushable(b)) => b.item.clone(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(mut item) = item {
|
||||
let face = face.unwrap_or([0, 1, 0]);
|
||||
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
|
||||
@@ -221,12 +251,70 @@ impl Game<'_> {
|
||||
if brush.count != 0 {
|
||||
self.brushing.insert(pos, brush);
|
||||
self.schedule_brushable_tick(pos);
|
||||
} else if let Some(BlockEntity::Brushable(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.hit_direction = None;
|
||||
}
|
||||
if before != after {
|
||||
self.set_dusted(pos, after);
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrushableBlockRenderer`: reveal the item from the first brushed face.
|
||||
pub(super) fn brushable_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Brushable(b) = entity else {
|
||||
continue;
|
||||
};
|
||||
let (Some(item), Some(face)) = (&b.item, b.hit_direction) else {
|
||||
continue;
|
||||
};
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
let progress = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("dusted"))
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
if progress == 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = f32::from(progress) * 0.075;
|
||||
let (translation, adjacent, east_west) = match face {
|
||||
0 => (glam::Vec3::new(0.5, -0.23 - offset, 0.5), [0, -1, 0], false),
|
||||
1 => (glam::Vec3::new(0.5, 0.25 + offset, 0.5), [0, 1, 0], false),
|
||||
2 => (glam::Vec3::new(0.5, 0.0, 0.25 - offset), [0, 0, -1], false),
|
||||
3 => (glam::Vec3::new(0.5, 0.0, 0.73 + offset), [0, 0, 1], false),
|
||||
4 => (glam::Vec3::new(0.25 - offset, 0.0, 0.5), [-1, 0, 0], true),
|
||||
5 => (glam::Vec3::new(0.73 + offset, 0.0, 0.5), [1, 0, 0], true),
|
||||
_ => continue,
|
||||
};
|
||||
let item = self.resolve_item(item, DisplayContext::Fixed, None, 0);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pose = glam::Mat4::from_translation(
|
||||
(at - camera).as_vec3() + glam::Vec3::new(0.0, 0.5, 0.0) + translation,
|
||||
) * glam::Mat4::from_rotation_y(
|
||||
(86.0_f32 + if east_west { 90.0 } else { 0.0 }).to_radians(),
|
||||
) * glam::Mat4::from_scale(glam::Vec3::splat(0.5));
|
||||
let light = self.world.light(
|
||||
key.0 + adjacent[0],
|
||||
key.1 + adjacent[1],
|
||||
key.2 + adjacent[2],
|
||||
);
|
||||
self.submit_item_world(&item, pose, vertex_light(light), out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
|
||||
/// and break particles.
|
||||
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
|
||||
|
||||
@@ -61,6 +61,11 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
|
||||
impl Game<'_> {
|
||||
/// Opening a screen stops breaking before the next game tick (which may pause).
|
||||
pub(super) fn stop_mining(&mut self) {
|
||||
if self.clicks.destroying.active {
|
||||
let pos = self.clicks.destroying.pos;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.tutorial_destroy(state, -1.0);
|
||||
}
|
||||
let mut destroying = std::mem::take(&mut self.clicks.destroying);
|
||||
destroying.stop(&mut GameHands {
|
||||
game: self,
|
||||
@@ -141,6 +146,12 @@ impl Game<'_> {
|
||||
progress: d.progress,
|
||||
ticks: d.ticks as u32,
|
||||
});
|
||||
if let Some(mining) = &self.mining {
|
||||
let pos = mining.pos;
|
||||
let progress = mining.progress;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.tutorial_destroy(state, progress);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
|
||||
@@ -578,6 +589,8 @@ impl HandWorld for GameHands<'_, '_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let state = self.game.world.block(pos[0], pos[1], pos[2]);
|
||||
self.game.tutorial_destroy(state, 1.0);
|
||||
let r = self.game.break_block(pos);
|
||||
self.keep(r);
|
||||
true
|
||||
|
||||
@@ -151,10 +151,14 @@ impl Containers<'_> {
|
||||
});
|
||||
}
|
||||
let entity = self.world.block_entities.get(&pos)?;
|
||||
// A beehive keeps bees, not items, and a campfire cooks its own: neither
|
||||
// is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops
|
||||
// in front of them and a hopper leaves them alone.
|
||||
if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) {
|
||||
// A beehive keeps bees, not items, a campfire cooks its own and
|
||||
// suspicious sand or gravel hides its loot: none is a `Container`
|
||||
// (HopperBlockEntity.getContainerAt), so a dropper drops in front of
|
||||
// them and a hopper leaves them alone.
|
||||
if matches!(
|
||||
entity,
|
||||
BlockEntity::Beehive(_) | BlockEntity::Campfire(_) | BlockEntity::Brushable(_)
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let kind = match (entity, name) {
|
||||
@@ -422,15 +426,17 @@ pub(super) fn shelf_signal(
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
|
||||
// Only read from the back: a bit for each of the three slots in use.
|
||||
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
|
||||
if facing.map(Direction::opposite) != Some(direction) {
|
||||
return Some(0);
|
||||
}
|
||||
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
|
||||
let Some(BlockEntity::Shelf(shelf)) = world.block_entities.get(&(pos.x, pos.y, pos.z)) else {
|
||||
return Some(0);
|
||||
};
|
||||
Some(
|
||||
items
|
||||
shelf
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, item)| i32::from(item.is_some()) << slot)
|
||||
@@ -473,6 +479,12 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.chiseled_bookshelf_synced(pos);
|
||||
if matches!(
|
||||
self.world.block_entities.get(&pos),
|
||||
Some(BlockEntity::Shelf(_))
|
||||
) {
|
||||
self.mark_block_entity_updated(pos);
|
||||
}
|
||||
// A double chest's other half changes with it.
|
||||
let halves: Vec<Pos> = std::iter::once(pos)
|
||||
.chain(self.chest_other_half(pos))
|
||||
|
||||
@@ -39,6 +39,7 @@ use horizon_protocol::{
|
||||
text::{Language, plain},
|
||||
};
|
||||
use horizon_world::{
|
||||
level::InteractionResult,
|
||||
nbt::Tag,
|
||||
registry::BlockStateId,
|
||||
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
|
||||
@@ -347,6 +348,7 @@ impl Game<'_> {
|
||||
local: Option<horizon_protocol::connection::Pipe>,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
self.reset_tutorial();
|
||||
self.perf.world_opening();
|
||||
let root = self.options.resources.clone();
|
||||
let builtin = Builtin::find(&root)
|
||||
@@ -556,6 +558,7 @@ impl Game<'_> {
|
||||
self.gui.atlas.object_skins.clear();
|
||||
self.server_packs_disconnect();
|
||||
self.code_of_conduct = None;
|
||||
self.reset_tutorial();
|
||||
self.close_dialogs();
|
||||
// Gui.setScreen -> KeyMapping.releaseAll: the title or disconnect
|
||||
// screen releases every held key. Its release goes to that screen,
|
||||
@@ -2750,6 +2753,7 @@ impl Game<'_> {
|
||||
r.state_id = p.state;
|
||||
}
|
||||
let stack = self.to_stack(&p.item);
|
||||
self.tutorial_get_item(stack.as_ref());
|
||||
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
|
||||
// gains items squeezes for 5 ticks.
|
||||
if p.container == 0
|
||||
@@ -2768,9 +2772,14 @@ impl Game<'_> {
|
||||
self.set_menu_slot(p.container, i32::from(p.slot), stack);
|
||||
self.remote_synced_since(&before);
|
||||
}
|
||||
C::SetCursorItem(p) => self.carried = self.to_stack(&p.item),
|
||||
C::SetCursorItem(p) => {
|
||||
let stack = self.to_stack(&p.item);
|
||||
self.tutorial_get_item(stack.as_ref());
|
||||
self.carried = stack;
|
||||
}
|
||||
C::SetPlayerInventory(p) => {
|
||||
let stack = self.to_stack(&p.item);
|
||||
self.tutorial_get_item(stack.as_ref());
|
||||
let before = self.creative_stacks();
|
||||
match p.slot {
|
||||
0..=35 => self.inventory[p.slot as usize] = stack,
|
||||
@@ -4598,6 +4607,8 @@ impl Game<'_> {
|
||||
/// the attack strength again.
|
||||
pub(super) fn stop_remote_mining(&mut self) {
|
||||
if let Some((pos, _, _)) = self.remote.as_mut().and_then(|r| r.destroying.take()) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
self.tutorial_destroy(state, -1.0);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 2,
|
||||
pos,
|
||||
@@ -4668,6 +4679,8 @@ impl Game<'_> {
|
||||
}
|
||||
if !same {
|
||||
if let Some((old, _, _)) = self.remote.as_mut().and_then(|r| r.destroying.take()) {
|
||||
let old_state = self.world.block(old[0], old[1], old[2]);
|
||||
self.tutorial_destroy(old_state, -1.0);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
action: 2,
|
||||
pos: old,
|
||||
@@ -4682,6 +4695,7 @@ impl Game<'_> {
|
||||
direction: face,
|
||||
sequence: seq,
|
||||
}));
|
||||
self.tutorial_destroy(state, 0.0);
|
||||
if progress >= 1.0 {
|
||||
// startDestroyBlock: instant survival breaks have no destroy delay.
|
||||
self.predict_break(hit.pos, state, seq);
|
||||
@@ -4705,6 +4719,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let done = done + progress;
|
||||
self.tutorial_destroy(state, done.clamp(0.0, 1.0));
|
||||
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
|
||||
if done >= 1.0 {
|
||||
let seq = self.next_sequence();
|
||||
@@ -4741,6 +4756,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// MultiPlayerGameMode.destroyBlock: the predicted break.
|
||||
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId, sequence: i32) {
|
||||
self.tutorial_destroy(state, 1.0);
|
||||
// BlockStatePredictionHandler.retainKnownServerState.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.predictions
|
||||
@@ -4863,18 +4879,32 @@ impl Game<'_> {
|
||||
.and_then(|h| self.res.items().get(&h.item))
|
||||
.is_some_and(|i| i.block.is_some());
|
||||
if let Some(hit) = self.pick() {
|
||||
// ShelfBlock.useItemOn consumes a nonempty main-hand click client-side
|
||||
// without predicting the inventory swap or swinging.
|
||||
let shelf_reacts = hand == Hand::Main
|
||||
&& held.is_some()
|
||||
&& !(self.keys.contains(&KeyCode::ShiftLeft)
|
||||
&& (self.stack_in(self.selected).is_some() || self.offhand.is_some()))
|
||||
&& self.shelf_hit(&hit).is_some();
|
||||
let spectator = self.spectator();
|
||||
let (reacts, changes) = if hand == Hand::Main && !spectator {
|
||||
let (result, changes) = if hand == Hand::Main && !spectator {
|
||||
self.remote_block_reacts(&hit, held.as_ref())
|
||||
} else {
|
||||
// useItemOn runs for the off hand too (useWithoutItem doesn't):
|
||||
// RespawnAnchorBlock.useItemOn charges an anchor with its glowstone.
|
||||
// RespawnAnchorBlock.useItemOn charges an anchor with its
|
||||
// glowstone, SUCCESS.
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
|
||||
let charges =
|
||||
!(sneaking && hands_full) && self.anchor_takes(hit.pos, held.as_ref());
|
||||
(charges, DryUse::default())
|
||||
let result = if charges {
|
||||
InteractionResult::Success
|
||||
} else {
|
||||
InteractionResult::Pass
|
||||
};
|
||||
(result, DryUse::default())
|
||||
};
|
||||
let reacts = result.consumes_action();
|
||||
let seq = self.next_sequence();
|
||||
let cursor = [
|
||||
(hit.point[0] - f64::from(hit.pos[0])) as f32,
|
||||
@@ -4902,9 +4932,17 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.use_target = Some(hit.pos);
|
||||
}
|
||||
if shelf_reacts {
|
||||
return true;
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
|
||||
// (a chest, barrel, furnace, …). CONSUME does not swing.
|
||||
if result == InteractionResult::Success {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
|
||||
// CampfireBlock.useItemOn succeeded: this hand swings and the
|
||||
// click ends, so a shield in the other hand is not raised.
|
||||
@@ -4943,7 +4981,7 @@ impl Game<'_> {
|
||||
// server's resend of the target it refused is no placement.
|
||||
r.placing = None;
|
||||
}
|
||||
if reacts || placed.is_some() {
|
||||
if placed.is_some() {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
// BlockItem.place returns FAIL when placement is rejected:
|
||||
@@ -5035,20 +5073,20 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
hit: &super::Hit,
|
||||
held: Option<&ItemStack>,
|
||||
) -> (bool, DryUse) {
|
||||
) -> (InteractionResult, DryUse) {
|
||||
// Player.isSecondaryUseActive with something in either hand.
|
||||
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
|
||||
return (false, DryUse::default());
|
||||
return (InteractionResult::Pass, DryUse::default());
|
||||
}
|
||||
if self.jukebox_takes(hit.pos, held) {
|
||||
return (true, DryUse::default());
|
||||
return (InteractionResult::Success, DryUse::default());
|
||||
}
|
||||
// RespawnAnchorBlock.useItemOn: PASS when the off hand's glowstone is to
|
||||
// charge the anchor, so useWithoutItem doesn't run and the off hand's turn
|
||||
// comes (glowstone in this hand charges it in `use_item_on`).
|
||||
if !self.anchor_takes(hit.pos, held) && self.anchor_takes(hit.pos, self.offhand.as_ref()) {
|
||||
return (false, DryUse::default());
|
||||
return (InteractionResult::Pass, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
@@ -5058,7 +5096,7 @@ impl Game<'_> {
|
||||
.hive_state(hit.pos)
|
||||
.is_some_and(|(honey, _)| honey == Some(5))
|
||||
{
|
||||
return (true, DryUse::default());
|
||||
return (InteractionResult::Success, DryUse::default());
|
||||
}
|
||||
use horizon_core::geometry::Direction;
|
||||
let face = match hit.face {
|
||||
@@ -5072,18 +5110,16 @@ impl Game<'_> {
|
||||
let player = self.use_context();
|
||||
self.with_level(|b, level, _| {
|
||||
let mut level = DryLevel::new(level);
|
||||
let reacts = b
|
||||
.use_item_on(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
face,
|
||||
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
|
||||
player,
|
||||
held.map(|s| &*s.item),
|
||||
)
|
||||
.consumes_action();
|
||||
(reacts, (level.changed.into_iter().collect(), level.sounds))
|
||||
let result = b.use_item_on(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
face,
|
||||
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
|
||||
player,
|
||||
held.map(|s| &*s.item),
|
||||
);
|
||||
(result, (level.changed.into_iter().collect(), level.sounds))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5711,6 +5747,9 @@ impl Game<'_> {
|
||||
}
|
||||
KeyCode::KeyE => {
|
||||
// The inventory opens without telling the server.
|
||||
if self.tutorial.open_inventory() {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
self.recipe_ui.reset_screen();
|
||||
if self.creative {
|
||||
self.screen = Screen::Creative;
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! The displayed items of `ShelfBlockEntity`.
|
||||
use super::{Game, Hit, machines::dir_from_name};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{block_entity_render::vertex_light, containers::BlockEntity, mesher::Vertex};
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::level::hit_slot;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Slot and facing for a click on a shelf's front face.
|
||||
pub(super) fn shelf_hit(&self, hit: &Hit) -> Option<(usize, Direction, bool)> {
|
||||
let p = hit.pos;
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&(p[0], p[1], p[2])),
|
||||
Some(BlockEntity::Shelf(_))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let data = self.pack.assets.registry.state(state)?;
|
||||
let facing = data
|
||||
.properties
|
||||
.get("facing")
|
||||
.and_then(|s| dir_from_name(s))?;
|
||||
let face = Direction::ALL
|
||||
.into_iter()
|
||||
.find(|d| d.normal() == hit.face)?;
|
||||
let slot = hit_slot(
|
||||
facing,
|
||||
face,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
|
||||
1,
|
||||
3,
|
||||
)?;
|
||||
Some((
|
||||
slot,
|
||||
facing,
|
||||
data.properties.get("powered").is_some_and(|s| s == "true"),
|
||||
))
|
||||
}
|
||||
|
||||
/// `SideChainPartBlock.getAllBlocksConnectedTo`: left to right, at most three.
|
||||
fn shelf_chain(&self, pos: [i32; 3], facing: Direction) -> Vec<[i32; 3]> {
|
||||
let left = match facing {
|
||||
Direction::North => Direction::East,
|
||||
Direction::East => Direction::South,
|
||||
Direction::South => Direction::West,
|
||||
Direction::West => Direction::North,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let behaviours = self.res.behaviours();
|
||||
if !behaviours
|
||||
.tag("minecraft:wooden_shelves")
|
||||
.is_some_and(|tag| tag.contains(behaviours.block(state)))
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut result = vec![pos];
|
||||
for (dir, end, prepend) in [(left, "left", true), (left.opposite(), "right", false)] {
|
||||
let normal = dir.normal();
|
||||
for step in 1..3 {
|
||||
let p = [pos[0] + normal[0] * step, pos[1], pos[2] + normal[2] * step];
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let Some(info) = self.pack.assets.registry.state(state) else {
|
||||
break;
|
||||
};
|
||||
let behaviours = self.res.behaviours();
|
||||
let connectable = behaviours
|
||||
.tag("minecraft:wooden_shelves")
|
||||
.is_some_and(|tag| tag.contains(behaviours.block(state)))
|
||||
&& info.properties.get("powered").is_some_and(|s| s == "true")
|
||||
&& info.properties.get("facing").and_then(|s| dir_from_name(s)) == Some(facing);
|
||||
if !connectable {
|
||||
break;
|
||||
}
|
||||
let part = info
|
||||
.properties
|
||||
.get("side_chain")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("unconnected");
|
||||
if part == "center" || part == end {
|
||||
if prepend {
|
||||
result.insert(0, p);
|
||||
} else {
|
||||
result.push(p);
|
||||
}
|
||||
}
|
||||
if part != "center" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// `ShelfBlock.useItemOn`: a single swap, or a connected hotbar swap while powered.
|
||||
pub(super) fn use_shelf(&mut self, hit: &Hit) -> Option<bool> {
|
||||
use horizon_client::first_person::Hand;
|
||||
if self.hand != Hand::Main {
|
||||
return None;
|
||||
}
|
||||
let (slot, facing, powered) = self.shelf_hit(hit)?;
|
||||
let pos = hit.pos;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if powered {
|
||||
let chain = self.shelf_chain(pos, facing);
|
||||
let mut swapped = false;
|
||||
let count = chain.len();
|
||||
for (i, p) in chain.into_iter().enumerate() {
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for n in 0..3 {
|
||||
let index = 9_i32 - (count as i32 - i as i32) * 3 + n as i32;
|
||||
if !(0..9).contains(&index) {
|
||||
continue;
|
||||
}
|
||||
let index = index as usize;
|
||||
swapped |= self.inventory[index].is_some() || shelf.items[n].is_some();
|
||||
std::mem::swap(&mut self.inventory[index], &mut shelf.items[n]);
|
||||
}
|
||||
self.container_changed(key);
|
||||
let s = self.world.block(p[0], p[1], p[2]);
|
||||
self.post_block_game_event("minecraft:entity_interact", p, None, Some(s));
|
||||
}
|
||||
if !swapped {
|
||||
return Some(false);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:block.shelf.multi_swap",
|
||||
Some(pos.map(|n| f64::from(n) + 0.5)),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
Some(true)
|
||||
} else {
|
||||
let held = self.inventory[self.selected].clone();
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key) else {
|
||||
return None;
|
||||
};
|
||||
let removed = std::mem::replace(&mut shelf.items[slot], held.clone());
|
||||
self.inventory[self.selected] = if self.creative && removed.is_none() {
|
||||
held.clone()
|
||||
} else {
|
||||
removed.clone()
|
||||
};
|
||||
self.container_changed(key);
|
||||
let vibrations = self.inventory[self.selected].as_ref().is_none_or(|stack| {
|
||||
let default = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_none_or(|item| item.interact_vibrations);
|
||||
let Some(data) = stack.data() else {
|
||||
return default;
|
||||
};
|
||||
if data
|
||||
.other
|
||||
.iter()
|
||||
.any(|(key, _)| key == "!minecraft:use_effects")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
data.other
|
||||
.iter()
|
||||
.find(|(key, _)| key == "minecraft:use_effects")
|
||||
.map_or(default, |(_, tag)| {
|
||||
horizon_client::save::number(tag.get("interact_vibrations")).unwrap_or(1)
|
||||
!= 0
|
||||
})
|
||||
});
|
||||
if vibrations {
|
||||
self.post_block_game_event(
|
||||
"minecraft:item_interact_finish",
|
||||
pos,
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
let sound = match (held.is_some(), removed.is_some()) {
|
||||
(false, false) => return None,
|
||||
(true, false) => "minecraft:block.shelf.place_item",
|
||||
(false, true) => "minecraft:block.shelf.take_item",
|
||||
(true, true) => "minecraft:block.shelf.single_swap",
|
||||
};
|
||||
self.audio
|
||||
.play(sound, Some(pos.map(|n| f64::from(n) + 0.5)), 1.0, 1.0);
|
||||
Some(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShelfRenderer`: three items face out, aligned to the bottom or centre.
|
||||
pub(super) fn shelf_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Shelf(shelf) = entity else {
|
||||
continue;
|
||||
};
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
let block = self.world.block(key.0, key.1, key.2);
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(block)
|
||||
.and_then(|s| s.properties.get("facing"))
|
||||
.map(String::as_str)
|
||||
.unwrap_or("north");
|
||||
let yaw: f32 = match facing {
|
||||
"south" => 0.0,
|
||||
"west" => -90.0,
|
||||
"north" => -180.0,
|
||||
"east" => -270.0,
|
||||
_ => 180.0,
|
||||
};
|
||||
let packed = BlockPos::new(key.0, key.1, key.2).pack() as u64;
|
||||
let seed = (packed ^ (packed >> 32)) as i32;
|
||||
let light = vertex_light(self.world.light(key.0, key.1, key.2));
|
||||
for (slot, stack) in shelf.items.iter().enumerate() {
|
||||
let Some(stack) = stack else { continue };
|
||||
let item = self.resolve_item(
|
||||
stack,
|
||||
DisplayContext::OnShelf,
|
||||
None,
|
||||
seed.wrapping_add(slot as i32),
|
||||
);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = item.bounding_box();
|
||||
let offset_y = -lo.y
|
||||
- if shelf.align_to_bottom {
|
||||
0.0
|
||||
} else {
|
||||
(hi.y - lo.y) / 2.0
|
||||
};
|
||||
let pose =
|
||||
glam::Mat4::from_translation((at - camera).as_vec3() + glam::Vec3::splat(0.5))
|
||||
* glam::Mat4::from_rotation_y(yaw.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(
|
||||
(slot as f32 - 1.0) * 0.3125,
|
||||
if shelf.align_to_bottom { -0.25 } else { 0.0 },
|
||||
-0.25,
|
||||
))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(0.25))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, offset_y, 0.0));
|
||||
self.submit_item_world(&item, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
//! Client tutorial progression (`net.minecraft.client.tutorial`).
|
||||
|
||||
/// The numeric values are the order in `options_file::TUTORIAL_STEPS`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Step {
|
||||
Movement = 0,
|
||||
FindTree = 1,
|
||||
PunchTree = 2,
|
||||
OpenInventory = 3,
|
||||
CraftPlanks = 4,
|
||||
None = 5,
|
||||
}
|
||||
|
||||
impl Step {
|
||||
pub(super) fn from_option(value: i32) -> Self {
|
||||
match value {
|
||||
0 => Self::Movement,
|
||||
1 => Self::FindTree,
|
||||
2 => Self::PunchTree,
|
||||
3 => Self::OpenInventory,
|
||||
4 => Self::CraftPlanks,
|
||||
_ => Self::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Hint {
|
||||
Move,
|
||||
Look,
|
||||
FindTree,
|
||||
PunchTree,
|
||||
OpenInventory,
|
||||
CraftPlanks,
|
||||
}
|
||||
|
||||
/// The observations needed on the first tick of each step.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Previous {
|
||||
pub(super) tree_item: bool,
|
||||
pub(super) log: bool,
|
||||
pub(super) plank: bool,
|
||||
pub(super) mined_tree: bool,
|
||||
pub(super) crafted_plank: bool,
|
||||
}
|
||||
|
||||
pub(super) struct Tutorial {
|
||||
pub(super) step: Step,
|
||||
pub(super) waiting: u32,
|
||||
moved: bool,
|
||||
turned: bool,
|
||||
move_ticks: u32,
|
||||
look_ticks: u32,
|
||||
move_finished: Option<u32>,
|
||||
look_finished: bool,
|
||||
reset_count: u32,
|
||||
pub(super) break_progress: f32,
|
||||
}
|
||||
|
||||
impl Tutorial {
|
||||
pub(super) fn new(step: Step) -> Self {
|
||||
Self {
|
||||
step,
|
||||
waiting: 0,
|
||||
moved: false,
|
||||
turned: false,
|
||||
move_ticks: 0,
|
||||
look_ticks: 0,
|
||||
move_finished: None,
|
||||
look_finished: false,
|
||||
reset_count: 0,
|
||||
break_progress: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the persisted step changed.
|
||||
fn set_step(&mut self, step: Step) -> bool {
|
||||
if self.step == step {
|
||||
return false;
|
||||
}
|
||||
*self = Self::new(step);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn input(&mut self) {
|
||||
self.moved = true;
|
||||
}
|
||||
pub(super) fn mouse(&mut self, x: f64, y: f64) {
|
||||
self.turned |= x.abs() > 0.01 || y.abs() > 0.01;
|
||||
}
|
||||
|
||||
pub(super) fn tick(&mut self, survival: bool, previous: &Previous) -> bool {
|
||||
self.waiting = self.waiting.saturating_add(1);
|
||||
if self.step == Step::Movement {
|
||||
self.move_ticks += u32::from(std::mem::take(&mut self.moved));
|
||||
self.look_ticks += u32::from(std::mem::take(&mut self.turned));
|
||||
if self.move_ticks > 40 && self.move_finished.is_none() {
|
||||
self.move_finished = Some(self.waiting);
|
||||
}
|
||||
self.look_finished |= self.look_ticks > 40;
|
||||
if self.move_finished.is_some() && self.look_finished {
|
||||
return self.set_step(if survival { Step::FindTree } else { Step::None });
|
||||
}
|
||||
} else if self.step != Step::None && !survival {
|
||||
return self.set_step(Step::None);
|
||||
} else if self.waiting == 1 {
|
||||
match self.step {
|
||||
Step::FindTree if previous.tree_item || previous.mined_tree => {
|
||||
return self.set_step(Step::CraftPlanks);
|
||||
}
|
||||
Step::PunchTree if previous.log || previous.mined_tree => {
|
||||
return self.set_step(Step::CraftPlanks);
|
||||
}
|
||||
Step::CraftPlanks if previous.plank || previous.crafted_plank => {
|
||||
return self.set_step(Step::None);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn look_at_tree(&mut self) -> bool {
|
||||
self.step == Step::FindTree && self.set_step(Step::PunchTree)
|
||||
}
|
||||
pub(super) fn destroy(&mut self, is_log: bool, progress: f32) -> bool {
|
||||
if self.step != Step::PunchTree {
|
||||
return false;
|
||||
}
|
||||
if is_log && progress > 0.0 {
|
||||
self.break_progress = progress;
|
||||
if progress >= 1.0 {
|
||||
return self.set_step(Step::OpenInventory);
|
||||
}
|
||||
} else {
|
||||
self.break_progress = 0.0;
|
||||
if is_log {
|
||||
self.reset_count = self.reset_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
pub(super) fn open_inventory(&mut self) -> bool {
|
||||
self.step == Step::OpenInventory && self.set_step(Step::CraftPlanks)
|
||||
}
|
||||
pub(super) fn get_item(&mut self, tree: bool, log: bool, plank: bool) -> bool {
|
||||
match self.step {
|
||||
Step::FindTree if tree => self.set_step(Step::CraftPlanks),
|
||||
Step::PunchTree if log => self.set_step(Step::CraftPlanks),
|
||||
Step::CraftPlanks if plank => self.set_step(Step::None),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub(super) fn hint(&self) -> Option<(Hint, Option<f32>)> {
|
||||
match self.step {
|
||||
Step::Movement if self.waiting >= 100 && self.move_finished.is_none() => {
|
||||
Some((Hint::Move, Some(self.move_ticks as f32 / 40.0)))
|
||||
}
|
||||
Step::Movement
|
||||
if !self.look_finished
|
||||
&& self
|
||||
.move_finished
|
||||
.is_some_and(|t| self.waiting.saturating_sub(t) >= 20)
|
||||
&& self.waiting >= 100 =>
|
||||
{
|
||||
Some((Hint::Look, Some(self.look_ticks as f32 / 40.0)))
|
||||
}
|
||||
Step::FindTree if self.waiting >= 6000 => Some((Hint::FindTree, None)),
|
||||
Step::PunchTree if self.waiting >= 600 || self.reset_count > 3 => {
|
||||
Some((Hint::PunchTree, Some(self.break_progress)))
|
||||
}
|
||||
Step::OpenInventory if self.waiting >= 600 => Some((Hint::OpenInventory, None)),
|
||||
Step::CraftPlanks if self.waiting >= 1200 => Some((Hint::CraftPlanks, None)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Game<'_> {
|
||||
/// `Tutorial.setStep` persists immediately so it survives leaving a world.
|
||||
pub(super) fn persist_tutorial_step(&mut self) {
|
||||
self.settings.file.set(
|
||||
"tutorialStep",
|
||||
horizon_client::options_file::V::Int(self.tutorial.step as i32),
|
||||
);
|
||||
self.settings.save(&Self::options_path());
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Game<'_> {
|
||||
pub(super) fn reset_tutorial(&mut self) {
|
||||
self.tutorial = Tutorial::new(Step::from_option(self.settings.file.int("tutorialStep")));
|
||||
self.tutorial_toasts.clear();
|
||||
}
|
||||
|
||||
fn tutorial_tagged(&self, item: bool, tag: &str, id: &str) -> bool {
|
||||
let items = self.res.items();
|
||||
let tags = if item {
|
||||
&items.item_tags
|
||||
} else {
|
||||
&items.block_tags
|
||||
};
|
||||
tags.get(tag).is_some_and(|members| members.contains(id))
|
||||
}
|
||||
|
||||
pub(super) fn tutorial_get_item(&mut self, stack: Option<&horizon_client::items::ItemStack>) {
|
||||
if self.options.dedicated.is_some() || self.title.is_some() || self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(stack) = stack else { return };
|
||||
let id = &*stack.item;
|
||||
let tree = self.tutorial_tagged(true, "minecraft:completes_find_tree_tutorial", id);
|
||||
let log = self.tutorial_tagged(true, "minecraft:logs", id);
|
||||
let plank = self.tutorial_tagged(true, "minecraft:planks", id);
|
||||
if self.tutorial.get_item(tree, log, plank) {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tutorial_destroy(
|
||||
&mut self,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
progress: f32,
|
||||
) {
|
||||
if self.options.dedicated.is_some() || self.title.is_some() || self.player.is_none() {
|
||||
return;
|
||||
}
|
||||
let name = &self.res.facts().get(state).name;
|
||||
let is_log = self.tutorial_tagged(false, "minecraft:logs", name);
|
||||
if self.tutorial.destroy(is_log, progress) {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tick_tutorial(&mut self) {
|
||||
use winit::keyboard::KeyCode;
|
||||
if self.title.is_some() || self.player.is_none() || self.options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
if self.screen == super::Screen::Playing
|
||||
&& ([
|
||||
KeyCode::KeyW,
|
||||
KeyCode::KeyA,
|
||||
KeyCode::KeyS,
|
||||
KeyCode::KeyD,
|
||||
KeyCode::Space,
|
||||
]
|
||||
.iter()
|
||||
.any(|key| self.keys.contains(key))
|
||||
|| self
|
||||
.pad_move()
|
||||
.is_some_and(|[left, forward]| left.abs() > 0.3 || forward.abs() > 0.3))
|
||||
{
|
||||
self.tutorial.input();
|
||||
}
|
||||
let items = self.res.items();
|
||||
let in_tag =
|
||||
|tag: &str, id: &str| items.item_tags.get(tag).is_some_and(|set| set.contains(id));
|
||||
let mut previous = Previous::default();
|
||||
if self.tutorial.waiting == 0 && !matches!(self.tutorial.step, Step::Movement | Step::None)
|
||||
{
|
||||
for stack in self
|
||||
.inventory
|
||||
.iter()
|
||||
.chain(self.armor.iter())
|
||||
.flatten()
|
||||
.chain(self.offhand.iter())
|
||||
{
|
||||
previous.tree_item |= in_tag("minecraft:completes_find_tree_tutorial", &stack.item);
|
||||
previous.log |= in_tag("minecraft:logs", &stack.item);
|
||||
previous.plank |= in_tag("minecraft:planks", &stack.item);
|
||||
}
|
||||
if let Some(mined) = self.stats.values.get("minecraft:mined")
|
||||
&& let Some(tree) = items
|
||||
.block_tags
|
||||
.get("minecraft:completes_find_tree_tutorial")
|
||||
{
|
||||
previous.mined_tree = mined
|
||||
.iter()
|
||||
.any(|(id, count)| *count > 0 && tree.contains(id));
|
||||
}
|
||||
if let Some(crafted) = self.stats.values.get("minecraft:crafted")
|
||||
&& let Some(planks) = items.item_tags.get("minecraft:planks")
|
||||
{
|
||||
previous.crafted_plank = crafted
|
||||
.iter()
|
||||
.any(|(id, count)| *count > 0 && planks.contains(id));
|
||||
}
|
||||
}
|
||||
let survival = self.mode == horizon_client::command::args::GameMode::Survival;
|
||||
if self.tutorial.tick(survival, &previous) {
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
if self.tutorial.step == Step::FindTree
|
||||
&& self.pick_entity_hit().is_none()
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
|
||||
.name;
|
||||
if self.tutorial_tagged(false, "minecraft:completes_find_tree_tutorial", name)
|
||||
&& self.tutorial.look_at_tree()
|
||||
{
|
||||
self.persist_tutorial_step();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct HintToast {
|
||||
hint: Hint,
|
||||
lines: Vec<(horizon_client::text::Text, bool)>,
|
||||
title_lines: usize,
|
||||
progress: Option<f32>,
|
||||
smooth: f32,
|
||||
last_smooth: i64,
|
||||
wanted: bool,
|
||||
slot: Option<usize>,
|
||||
slots: usize,
|
||||
slide: horizon_client::toasts::Slide,
|
||||
}
|
||||
|
||||
impl super::Game<'_> {
|
||||
pub(super) fn tutorial_toast_slots(&self) -> u32 {
|
||||
self.tutorial_toasts
|
||||
.iter()
|
||||
.filter_map(|t| t.slot.map(|s| ((1u32 << t.slots) - 1) << s))
|
||||
.fold(0, |a, b| a | b)
|
||||
}
|
||||
|
||||
fn tutorial_toast(&self, hint: Hint, progress: Option<f32>) -> HintToast {
|
||||
use horizon_client::{keybinds, text::Text};
|
||||
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping)).plain();
|
||||
let (title_key, fallback, description_key, description, icon_args) = match hint {
|
||||
Hint::Move => (
|
||||
"tutorial.move.title",
|
||||
"Move with %1$s, %2$s, %3$s, %4$s",
|
||||
"tutorial.move.description",
|
||||
"Jump with %1$s",
|
||||
vec![
|
||||
key("key.forward"),
|
||||
key("key.left"),
|
||||
key("key.back"),
|
||||
key("key.right"),
|
||||
],
|
||||
),
|
||||
Hint::Look => (
|
||||
"tutorial.look.title",
|
||||
"Look around",
|
||||
"tutorial.look.description",
|
||||
"Use your mouse to turn",
|
||||
vec![],
|
||||
),
|
||||
Hint::FindTree => (
|
||||
"tutorial.find_tree.title",
|
||||
"Find a tree",
|
||||
"tutorial.find_tree.description",
|
||||
"Punch it to collect wood",
|
||||
vec![],
|
||||
),
|
||||
Hint::PunchTree => (
|
||||
"tutorial.punch_tree.title",
|
||||
"Punch the tree",
|
||||
"tutorial.punch_tree.description",
|
||||
"Hold %1$s to break",
|
||||
vec![],
|
||||
),
|
||||
Hint::OpenInventory => (
|
||||
"tutorial.open_inventory.title",
|
||||
"Open inventory",
|
||||
"tutorial.open_inventory.description",
|
||||
"Press %1$s",
|
||||
vec![],
|
||||
),
|
||||
Hint::CraftPlanks => (
|
||||
"tutorial.craft_planks.title",
|
||||
"Craft wooden planks",
|
||||
"tutorial.craft_planks.description",
|
||||
"The recipe book can help",
|
||||
vec![],
|
||||
),
|
||||
};
|
||||
let substitute = |mut text: String, values: &[String]| {
|
||||
for (i, value) in values.iter().enumerate() {
|
||||
text = text.replace(&format!("%{}$s", i + 1), value);
|
||||
text = text.replacen("%s", value, 1);
|
||||
}
|
||||
text
|
||||
};
|
||||
let title = substitute(self.translate(title_key, fallback), &icon_args);
|
||||
let desc_args = match hint {
|
||||
Hint::Move => vec![key("key.jump")],
|
||||
Hint::PunchTree => vec![key("key.attack")],
|
||||
Hint::OpenInventory => vec![key("key.inventory")],
|
||||
_ => vec![],
|
||||
};
|
||||
let description = substitute(self.translate(description_key, description), &desc_args);
|
||||
let font = &self.gui.atlas.font;
|
||||
let mut lines = font.split_lines(&Text::literal(title), 126.0);
|
||||
let title_lines = lines.len();
|
||||
lines.extend(font.split_lines(&Text::literal(description), 126.0));
|
||||
let height = 10 + 11 * lines.len().max(2);
|
||||
let slots = height.div_ceil(32);
|
||||
HintToast {
|
||||
hint,
|
||||
lines,
|
||||
title_lines,
|
||||
progress,
|
||||
smooth: 0.0,
|
||||
last_smooth: 0,
|
||||
wanted: true,
|
||||
slot: None,
|
||||
slots,
|
||||
slide: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_tutorial_toasts(
|
||||
&mut self,
|
||||
batch: &mut horizon_client::hud::HudBatch,
|
||||
hidden: bool,
|
||||
) {
|
||||
let wanted = self.tutorial.hint();
|
||||
for t in &mut self.tutorial_toasts {
|
||||
t.wanted = wanted.is_some_and(|(hint, _)| hint == t.hint);
|
||||
if t.wanted {
|
||||
t.progress = wanted.and_then(|(_, progress)| progress);
|
||||
}
|
||||
}
|
||||
if let Some((hint, progress)) = wanted
|
||||
&& !self
|
||||
.tutorial_toasts
|
||||
.iter()
|
||||
.any(|t| t.wanted && t.hint == hint)
|
||||
{
|
||||
let toast = self.tutorial_toast(hint, progress);
|
||||
self.tutorial_toasts.push(toast);
|
||||
}
|
||||
let mut used = self.toast_slots_used();
|
||||
for t in &mut self.tutorial_toasts {
|
||||
if t.slot.is_none() && t.wanted && t.slots <= Self::TOAST_SLOTS {
|
||||
let mask = (1u32 << t.slots) - 1;
|
||||
if let Some(slot) =
|
||||
(0..=Self::TOAST_SLOTS - t.slots).find(|s| used & (mask << s) == 0)
|
||||
{
|
||||
t.slot = Some(slot);
|
||||
used |= mask << slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut i = 0;
|
||||
while i < self.tutorial_toasts.len() {
|
||||
let t = &mut self.tutorial_toasts[i];
|
||||
if t.slot.is_none() {
|
||||
if !t.wanted {
|
||||
self.tutorial_toasts.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (sound, finished) = t.slide.update_now(|_| t.wanted);
|
||||
if let Some(show) = sound {
|
||||
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
|
||||
self.audio
|
||||
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
|
||||
}
|
||||
if finished {
|
||||
self.tutorial_toasts.remove(i);
|
||||
continue;
|
||||
}
|
||||
if let Some(progress) = t.progress {
|
||||
let delta = ((t.slide.fully_for - t.last_smooth) as f32 / 100.0).clamp(0.0, 1.0);
|
||||
t.smooth += (progress - t.smooth) * delta;
|
||||
t.last_smooth = t.slide.fully_for;
|
||||
}
|
||||
if !hidden {
|
||||
let height = (10 + 11 * t.lines.len().max(2)) as f32;
|
||||
let x = batch.width - 160.0 * t.slide.portion;
|
||||
let y = 32.0 * t.slot.unwrap_or_default() as f32;
|
||||
horizon_client::ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/sprites/toast/tutorial",
|
||||
x,
|
||||
y,
|
||||
160.0,
|
||||
height,
|
||||
u32::MAX,
|
||||
);
|
||||
let icon = match t.hint {
|
||||
Hint::Move => "movement_keys",
|
||||
Hint::Look => "mouse",
|
||||
Hint::FindTree | Hint::PunchTree => "tree",
|
||||
Hint::OpenInventory => "recipe_book",
|
||||
Hint::CraftPlanks => "wooden_planks",
|
||||
};
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
&format!("gui/sprites/toast/{icon}"),
|
||||
x + 6.0,
|
||||
y + 6.0,
|
||||
20.0,
|
||||
20.0,
|
||||
u32::MAX,
|
||||
);
|
||||
let text_y = y + 7.0 + ((t.lines.len().max(2) - t.lines.len()) * 11) as f32 / 2.0;
|
||||
for (n, (line, _)) in t.lines.iter().enumerate() {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
line,
|
||||
x + 30.0,
|
||||
text_y + 11.0 * n as f32,
|
||||
if n < t.title_lines {
|
||||
0xFF50_0050
|
||||
} else {
|
||||
0xFF00_0000
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
if let Some(progress) = t.progress {
|
||||
batch.fill(atlas, x + 3.0, y + height - 4.0, 154.0, 1.0, u32::MAX);
|
||||
let color = if progress >= t.smooth {
|
||||
0xFF00_5500
|
||||
} else {
|
||||
0xFF55_0000
|
||||
};
|
||||
batch.fill(
|
||||
atlas,
|
||||
x + 3.0,
|
||||
y + height - 4.0,
|
||||
154.0 * t.smooth.clamp(0.0, 1.0),
|
||||
1.0,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -940,6 +940,23 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
match entity {
|
||||
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
|
||||
BlockEntity::Spawner(s) => Some(s.update_tag()),
|
||||
BlockEntity::Brushable(b) => {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(face) = b.hit_direction {
|
||||
entries.push(("hit_direction", Tag::Byte(face as i8)));
|
||||
}
|
||||
if let Some(item) = &b.item {
|
||||
entries.push(("item", crate::save::item_to_nbt(item)));
|
||||
}
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
BlockEntity::Shelf(s) => Some(Tag::compound([
|
||||
("Items", crate::save::encode_items(&s.items)),
|
||||
(
|
||||
"align_items_to_bottom",
|
||||
Tag::Byte(i8::from(s.align_to_bottom)),
|
||||
),
|
||||
])),
|
||||
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
|
||||
// the custom name and the components.
|
||||
BlockEntity::Banner(b) => {
|
||||
|
||||
@@ -483,6 +483,13 @@ pub fn encode_block_entity(
|
||||
}
|
||||
}
|
||||
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
|
||||
BlockEntity::Shelf(shelf) => {
|
||||
entries.push(("Items", encode_items(&shelf.items)));
|
||||
entries.push((
|
||||
"align_items_to_bottom",
|
||||
Tag::Byte(i8::from(shelf.align_to_bottom)),
|
||||
));
|
||||
}
|
||||
// ChiseledBookShelfBlockEntity.saveAdditional.
|
||||
BlockEntity::ChiseledBookshelf(b) => {
|
||||
entries.push(("Items", encode_items(&b.items)));
|
||||
@@ -547,6 +554,16 @@ pub fn encode_block_entity(
|
||||
BlockEntity::Vault(v) => entries.extend(v.save()),
|
||||
BlockEntity::Beehive(h) => entries.extend(h.to_nbt()),
|
||||
BlockEntity::CommandBlock(c) => entries.extend(c.save()),
|
||||
BlockEntity::Brushable(b) => {
|
||||
if let Some(table) = &b.loot_table {
|
||||
entries.push(("LootTable", table.clone()));
|
||||
if let Some(seed) = &b.loot_seed {
|
||||
entries.push(("LootTableSeed", seed.clone()));
|
||||
}
|
||||
} else if let Some(item) = &b.item {
|
||||
entries.push(("item", item_to_nbt(item)));
|
||||
}
|
||||
}
|
||||
BlockEntity::DaylightDetector => {}
|
||||
}
|
||||
Tag::compound(entries)
|
||||
@@ -724,8 +741,24 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
_ => 1.0,
|
||||
};
|
||||
}
|
||||
BlockEntity::Brushable(b) => {
|
||||
b.loot_table = tag.get("LootTable").cloned();
|
||||
b.loot_seed = tag.get("LootTableSeed").cloned();
|
||||
b.item = if b.loot_table.is_none() {
|
||||
tag.get("item").and_then(item_from_nbt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
b.hit_direction = int("hit_direction").filter(|v| (0..=5).contains(v));
|
||||
}
|
||||
BlockEntity::Sign(sign) => sign.decode(tag),
|
||||
BlockEntity::Sculk(s) => s.load(tag),
|
||||
BlockEntity::Shelf(shelf) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 3).into_iter().enumerate() {
|
||||
shelf.items[slot] = item;
|
||||
}
|
||||
shelf.align_to_bottom = int("align_items_to_bottom").unwrap_or(0) != 0;
|
||||
}
|
||||
// ChiseledBookShelfBlockEntity.loadAdditional.
|
||||
BlockEntity::ChiseledBookshelf(b) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 6).into_iter().enumerate() {
|
||||
|
||||
@@ -641,7 +641,7 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
crate::structure_blocks::block_entities(&chunk, self.loot.as_deref());
|
||||
out.block_entities = block_entities;
|
||||
// The block entities Horizon does not model (decorated pots with their
|
||||
// sherds and loot table, brushable blocks) stay as placed, like a
|
||||
// sherds and loot table) stay as placed, like a
|
||||
// loaded chunk's.
|
||||
let mut unmodelled: Vec<Tag> = chunk
|
||||
.block_entities
|
||||
|
||||
@@ -107,6 +107,8 @@ pub struct ItemInfo {
|
||||
pub weapon_damage: Option<u32>,
|
||||
/// `use_effects.speed_multiplier` (UseEffects.DEFAULT: 0.2).
|
||||
pub use_speed: f32,
|
||||
/// `use_effects.interact_vibrations` (default true).
|
||||
pub interact_vibrations: bool,
|
||||
/// `glider` (elytra).
|
||||
pub glider: bool,
|
||||
/// `equippable.swappable` and `equip_sound`.
|
||||
@@ -663,6 +665,9 @@ impl Items {
|
||||
use_speed: c["minecraft:use_effects"]["speed_multiplier"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.2) as f32,
|
||||
interact_vibrations: c["minecraft:use_effects"]["interact_vibrations"]
|
||||
.as_bool()
|
||||
.unwrap_or(true),
|
||||
blocks_attacks: BlocksAttacks::parse(&c["minecraft:blocks_attacks"]),
|
||||
disable_blocking_seconds: c["minecraft:weapon"]["disable_blocking_for_seconds"]
|
||||
.as_f64()
|
||||
|
||||
@@ -567,6 +567,7 @@ props! {
|
||||
creaking_heart_state = "creaking_heart_state",
|
||||
cracked = "cracked",
|
||||
has_book = "has_book",
|
||||
has_record = "has_record",
|
||||
signal_fire = "signal_fire",
|
||||
orientation = "orientation",
|
||||
crafting = "crafting",
|
||||
|
||||
@@ -397,9 +397,11 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
| Class::BrewingStand
|
||||
| Class::CartographyTable
|
||||
| Class::Chest
|
||||
| Class::CopperChest
|
||||
| Class::Crafter
|
||||
| Class::CraftingTable
|
||||
| Class::Dispenser
|
||||
| Class::Dropper
|
||||
| Class::EnchantingTable
|
||||
| Class::EnderChest
|
||||
| Class::Grindstone
|
||||
@@ -409,10 +411,16 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
| Class::Sign
|
||||
| Class::SmithingTable
|
||||
| Class::Stonecutter
|
||||
| Class::TrappedChest
|
||||
| Class::Lectern => {
|
||||
self.player_effect(pos, state, PlayerEffect::OpenMenu);
|
||||
Success
|
||||
}
|
||||
// DecoratedPotBlock.useWithoutItem: the wobble always succeeds.
|
||||
Class::DecoratedPot => Success,
|
||||
// JukeboxBlock.useWithoutItem: ejecting a disc is SUCCESS (the
|
||||
// insert is useItemOn). An empty jukebox passes.
|
||||
Class::Jukebox if table.bool(state, p.has_record) => Success,
|
||||
Class::Command | Class::Structure | Class::Jigsaw => {
|
||||
if player.game_master {
|
||||
self.player_effect(pos, state, PlayerEffect::OpenMenu);
|
||||
|
||||
@@ -1474,6 +1474,44 @@ fn big_dripleaf_tilts_under_an_entity_standing_on_it() {
|
||||
assert_eq!(w.prop(pos, "tilt"), "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn using_storage_and_interactable_blocks_succeeds() {
|
||||
let mut w = world!();
|
||||
let pos = BlockPos::new(0, 1, 0);
|
||||
let cases: &[(&str, &[(&str, &str)])] = &[
|
||||
("chest", &[("facing", "north"), ("type", "single")]),
|
||||
("trapped_chest", &[("facing", "north"), ("type", "single")]),
|
||||
("barrel", &[("facing", "up")]),
|
||||
("ender_chest", &[("facing", "north")]),
|
||||
("shulker_box", &[("facing", "up")]),
|
||||
("dispenser", &[("facing", "north")]),
|
||||
("dropper", &[("facing", "north")]),
|
||||
("hopper", &[("facing", "down")]),
|
||||
("crafter", &[("orientation", "north_up")]),
|
||||
("furnace", &[("facing", "north")]),
|
||||
("blast_furnace", &[("facing", "north")]),
|
||||
("smoker", &[("facing", "north")]),
|
||||
("brewing_stand", &[]),
|
||||
("beacon", &[]),
|
||||
("decorated_pot", &[]),
|
||||
("jukebox", &[("has_record", "true")]),
|
||||
];
|
||||
for &(name, props) in cases {
|
||||
w.put(pos, w.id(name, props));
|
||||
assert_eq!(
|
||||
w.use_block(pos),
|
||||
InteractionResult::Success,
|
||||
"{name} should succeed (client swing)"
|
||||
);
|
||||
}
|
||||
w.put(pos, w.id("jukebox", &[("has_record", "false")]));
|
||||
assert_eq!(
|
||||
w.use_block(pos),
|
||||
InteractionResult::Pass,
|
||||
"an empty jukebox passes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creative_breaking_piston_head_does_not_drop_piston() {
|
||||
for (piston_name, head_type) in [("piston", "normal"), ("sticky_piston", "sticky")] {
|
||||
|
||||
@@ -102,6 +102,8 @@ impl Game {
|
||||
self.audio.play("hit", None, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
// Tutorial.onDestroyBlock (tutorial.rs): the hints play no part in mining.
|
||||
fn tutorial_destroy<T>(&mut self, _: T, _: f32) {}
|
||||
METHOD
|
||||
}
|
||||
fn main() {
|
||||
|
||||
Reference in new issue
Block a user