Compare commits

...
Author SHA1 Message Date
Ludovic Marie 25ee43da5d Merge branch 'heads/pt3/3327' into HEAD 2026-10-29 14:00:59 +01:00
Ludovic Marie 605fca4937 Merge branch 'heads/pt3/3303' into HEAD 2026-10-29 14:00:59 +01:00
Ludovic Marie d062afe0bc Merge branch 'heads/pt3/2964' into HEAD 2026-10-29 14:00:58 +01:00
Ludovic Marie 390f2e0941 Merge branch 'heads/pt3/2960' into HEAD 2026-10-29 14:00:58 +01:00
Ludovic MarieandClaude Opus 5.5 bc78cba5b9 Merge main into this branch
remote.rs: main's spectator check and off-hand anchor charge are kept with
this branch's InteractionResult: an anchor charged from the off hand is
SUCCESS (RespawnAnchorBlock.useItemOn) and swings, a jukebox taking a disc
is SUCCESS (JukeboxPlayable.tryInsertIntoJukebox), an anchor left to the
off hand's glowstone is PASS. level/tests.rs: both new tests kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:59:19 +01:00
Ludovic Marie ce8096db83 Merge remote-tracking branch 'origin/main' into pt3/2964
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-29 13:57:05 +01:00
Ludovic MarieandClaude Opus 5.5 98c0883c8f Merge main into this branch; suspicious blocks are not containers
remote.rs: main's spectator check (a spectator's use predicts nothing) and
this branch's shelf prediction are both kept, the spectator first.

machines.rs: the new Brushable block entity made the dispenser harness's
dropper under suspicious sand treat it as an empty container and drop
nothing; BrushableBlockEntity is no Container, so the dropper drops in
front of it as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:55:05 +01:00
Ludovic MarieandClaude Opus 5.5 49cf77a312 Merge main into this branch, stub the tutorial in the remote mining test
game.rs/remote.rs: main's creative screen reopen (#3187) and code of conduct
reset are kept beside this branch's tutorial steps. tests/test_remote_mining.py
compiles remote.rs's mining against its own small Game, which now needs a
no-op tutorial_destroy (verify failed with E0599 there).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:33:29 +01:00
Ludovic MarieandClaude Opus 5.5 cef862471f Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:13 +01:00
Ludovic MarieandClaude Opus 5.5 326719539d Merge main into fix/shelf-interaction
main's #2952 added banner, vault and trial spawner arms to lan::chunks::entity_tag next to this branch's brushable and shelf arms; both stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:32:54 +01:00
Muhammad Asif 1a9040ad79 Swing the arm when opening containers
MultiPlayerGameMode.useItemOn swings on InteractionResult.SUCCESS
(SwingSource.CLIENT). The remote client only swung when the held item
was a block, so opening a chest, barrel, furnace, or similar storage
did not start the arm animation.
2026-10-28 22:33:23 +05:00
Kacpherek 42388d2512 Swap items with shelves and powered shelf chains 2026-10-27 22:02:24 +01:00
Kacpherek 2deaab2602 Render items stored in shelves 2026-10-27 22:02:24 +01:00
Kacpherek 797482183c Show tutorial hints and track their progress 2026-10-27 22:02:18 +01:00
Kacpherek ca28a0edad Show items revealed by brushing suspicious blocks 2026-10-27 22:02:18 +01:00
19 changed files with 1187 additions and 44 deletions

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 {
+24 -5
View File
@@ -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(_)
+45 -2
View File
@@ -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) {
+1
View File
@@ -801,6 +801,7 @@ impl Game<'_> {
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
self.swing_arm();
return true;
}
if sneaking
+94 -6
View File
@@ -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) {
+13
View File
@@ -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
+19 -7
View File
@@ -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))
+61 -22
View File
@@ -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;
+260
View File
@@ -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);
}
}
}
}
+545
View File
@@ -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;
}
}
}
+17
View File
@@ -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) => {
+33
View File
@@ -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() {
+1 -1
View File
@@ -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
+5
View File
@@ -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()
+1
View File
@@ -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);
+38
View File
@@ -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")] {
+2
View File
@@ -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() {