diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index 5b6c26d2f..4688a5f45 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1061,6 +1061,7 @@ pub fn main() -> horizon_client::Result<()> { uuid: PLAYER_UUID, allies: Default::default(), remote: false, + passenger: false, } }); let mut scratch = LegacyRandom::new(0); diff --git a/crates/horizon-client/examples/probe/scene_motion_probe.rs b/crates/horizon-client/examples/probe/scene_motion_probe.rs index 6153e098b..0a7e4b594 100644 --- a/crates/horizon-client/examples/probe/scene_motion_probe.rs +++ b/crates/horizon-client/examples/probe/scene_motion_probe.rs @@ -353,6 +353,7 @@ pub fn main() -> Result<()> { horizon_world::food::Difficulty::Hard, ][op["difficulty"].as_u64().unwrap() as usize], natural_regeneration: op["regenerate"].as_bool().unwrap(), + ..Default::default() }; } "food" => { diff --git a/crates/horizon-client/src/cauldron.rs b/crates/horizon-client/src/cauldron.rs index 03fb0676d..25a709e26 100644 --- a/crates/horizon-client/src/cauldron.rs +++ b/crates/horizon-client/src/cauldron.rs @@ -40,6 +40,8 @@ pub struct Interaction { /// The cauldron's new state. pub block: Option, pub sound: Option<&'static str>, + /// The custom statistic the player earns (`use_cauldron`, ...). + pub stat: Option<&'static str>, } fn state(name: &str, level: Option) -> BlockState { @@ -57,6 +59,7 @@ fn pass() -> Interaction { outcome: Outcome::Pass, block: None, sound: None, + stat: None, } } @@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState { } } -fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction { +fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction { Interaction { outcome: Outcome::Success, block: Some(block), sound, + stat: Some(stat), } } @@ -179,18 +183,22 @@ pub fn interact( stack.data_mut().dyed_color = None; stack.tidy(); } - return changed(lowered(cauldron), None); + return changed(lowered(cauldron), None, "clean_armor"); } let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| { if !can_fill { return pass(); } filled(player, ItemStack::new(new, 1), true); - changed(state("minecraft:cauldron", None), Some(sound)) + changed( + state("minecraft:cauldron", None), + Some(sound), + "use_cauldron", + ) }; let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| { filled(player, ItemStack::new("minecraft:bucket", 1), true); - changed(block, Some(sound)) + changed(block, Some(sound), "fill_cauldron") }; match item { "minecraft:lava_bucket" => { @@ -199,6 +207,7 @@ pub fn interact( outcome: Outcome::Consume, block: None, sound: None, + stat: None, } } else { empty_bucket( @@ -221,6 +230,7 @@ pub fn interact( outcome: Outcome::Consume, block: None, sound: None, + stat: None, } } else { empty_bucket( @@ -241,6 +251,7 @@ pub fn interact( changed( state("minecraft:water_cauldron", Some(1)), Some("minecraft:item.bottle.empty"), + "use_cauldron", ) } ("water", "minecraft:bucket") => fill_bucket( @@ -255,7 +266,11 @@ pub fn interact( ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"), true, ); - changed(lowered(cauldron), Some("minecraft:item.bottle.fill")) + changed( + lowered(cauldron), + Some("minecraft:item.bottle.fill"), + "use_cauldron", + ) } ("water", "minecraft:potion") => { if level == 3 || !water_potion { @@ -266,7 +281,7 @@ pub fn interact( // BlockState.cycle on a 1..=3 property next.properties .insert("level".into(), (level % 3 + 1).to_string()); - changed(next, Some("minecraft:item.bottle.empty")) + changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron") } ("water", name) if name @@ -283,7 +298,7 @@ pub fn interact( cleaned.data_mut().banner_patterns.pop(); cleaned.tidy(); filled(player, cleaned, false); - changed(lowered(cauldron), None) + changed(lowered(cauldron), None, "clean_banner") } ("water", name) if name @@ -292,7 +307,7 @@ pub fn interact( .is_some_and(|c| COLORS.contains(&c)) => { filled(player, held.transmute("minecraft:shulker_box", 1), false); - changed(lowered(cauldron), None) + changed(lowered(cauldron), None, "clean_shulker_box") } ("lava", "minecraft:bucket") => fill_bucket( player, diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs index 8ca9af0ad..956d2b9a4 100644 --- a/crates/horizon-client/src/containers.rs +++ b/crates/horizon-client/src/containers.rs @@ -728,6 +728,18 @@ impl Furnace { .recipes_used .entry(recipe_used_key(&recipe.id)) .or_insert(0) += 1; + // AbstractFurnaceBlockEntity.burn: drying a wet sponge fills + // the bucket in the fuel slot. + if self.items[0] + .as_ref() + .is_some_and(|i| &*i.item == "minecraft:wet_sponge") + && self.items[1] + .as_ref() + .is_some_and(|f| &*f.item == "minecraft:bucket") + { + self.items[1] = + Some(ItemStack::new("minecraft:water_bucket", 1)); + } if let Some(i) = self.items[0].as_mut() { i.count -= 1; if i.count == 0 { diff --git a/crates/horizon-client/src/effects.rs b/crates/horizon-client/src/effects.rs index 16a7a273a..387de78b6 100644 --- a/crates/horizon-client/src/effects.rs +++ b/crates/horizon-client/src/effects.rs @@ -214,8 +214,9 @@ impl PlayerEffects { } /// `LivingEntity.tickEffects` (server part) with the omens' effect ticks: /// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect` - /// (on its last tick). Returns the actions to apply, in order, and - /// whether Raid Omen was newly added. + /// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s + /// answer: the effect ends once the absorption is used up. Returns the + /// actions to apply, in order, and whether Raid Omen was newly added. /// /// Bad Omen turning into a new Raid Omen adds an entry to the map being /// iterated: vanilla's iterator then throws a @@ -225,6 +226,7 @@ impl PlayerEffects { pub fn tick_with( &mut self, omen: &mut dyn FnMut(&EffectInstance) -> OmenTick, + absorption_empty: bool, ) -> (Vec, bool) { self.tick_count = self.tick_count.wrapping_add(1); let mut actions = Vec::new(); @@ -247,6 +249,7 @@ impl PlayerEffects { let answer = match e.id { "bad_omen" => omen(e), "raid_omen" if t == 1 => omen(e), + "absorption" if absorption_empty => OmenTick::Remove, _ => OmenTick::Continue, }; match answer { @@ -466,13 +469,13 @@ mod tests { }; // The first tick adds Raid Omen and stops there (the iterator's // ConcurrentModificationException): Bad Omen and Speed stay as they were. - let (_, added) = fx.tick_with(&mut convert); + let (_, added) = fx.tick_with(&mut convert, false); assert!(added); assert_eq!(fx.get("bad_omen").unwrap().duration, 100); assert_eq!(fx.get("speed").unwrap().duration, 50); assert_eq!(fx.get("raid_omen").unwrap().duration, 600); // The second refreshes Raid Omen and drops Bad Omen. - let (_, added) = fx.tick_with(&mut convert); + let (_, added) = fx.tick_with(&mut convert, false); assert!(!added); assert!(fx.get("bad_omen").is_none()); assert_eq!(fx.get("speed").unwrap().duration, 49); @@ -487,9 +490,9 @@ mod tests { seen.push(e.duration); OmenTick::Remove }; - fx.tick_with(&mut watch); + fx.tick_with(&mut watch, false); assert!(fx.get("raid_omen").is_some()); - fx.tick_with(&mut watch); + fx.tick_with(&mut watch, false); assert!(fx.get("raid_omen").is_none()); assert_eq!(seen, vec![1]); } diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index c65f35f5b..b2d1401da 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -498,10 +498,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { Tag::Int(i32::from(m.extra.variant.min(4))), ); } - // Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny). - "minecraft:rabbit" => { - map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant))); - } "minecraft:parrot" => { map.insert( "Variant".into(), diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 0719878eb..867e79d4b 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -570,6 +570,8 @@ struct GameLevel<'w> { player_collision: Option, /// Minecart boxes (detector rails). cart_boxes: Vec<([f64; 3], [f64; 3])>, + /// Each minecart's kind and comparator signal, in `cart_boxes` order. + cart_signals: Vec<(minecarts::CartKind, i32)>, /// Arrow and thrown trident boxes (wooden buttons). arrow_boxes: Vec<([f64; 3], [f64; 3])>, /// The other entities that keep blocks from being placed into them. @@ -648,6 +650,37 @@ impl EntityObstructionWorld for BuildingObstruction { } } impl GameLevel<'_> { + /// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the + /// minecart on it, a command block cart first (its success count, which + /// is 0 here), else the first container cart's fullness. + fn detector_rail_signal(&self, pos: BlockPos, powered: bool) -> i32 { + if !powered { + return 0; + } + let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z)); + let area = Aabb::new( + Vec3::new(x + 0.2, y, z + 0.2), + Vec3::new(x + 0.8, y + 0.8, z + 0.8), + ); + let mut container = None; + for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) { + let cart = Aabb::new( + Vec3::new(lo[0], lo[1], lo[2]), + Vec3::new(hi[0], hi[1], hi[2]), + ); + if !area.intersects(cart) { + continue; + } + match kind { + minecarts::CartKind::CommandBlock => return 0, + minecarts::CartKind::Chest | minecarts::CartKind::Hopper => { + container = container.or(Some(signal)); + } + _ => {} + } + } + container.unwrap_or(0) + } /// Every entity box that blocks building: the players who aren't /// spectators, the minecarts and the `Blockers` (a removed mob or a marker /// armor stand, whose box is empty, doesn't). @@ -708,6 +741,14 @@ impl LevelAccess for GameLevel<'_> { state: BlockStateId, _direction: Direction, ) -> i32 { + if let Some(s) = self.registry.state(state) + && s.name == "minecraft:detector_rail" + { + return self.detector_rail_signal( + pos, + s.properties.get("powered").is_some_and(|v| v == "true"), + ); + } if let Some(signal) = command_blocks::analog_signal(self.world, (pos.x, pos.y, pos.z)) { return signal; } @@ -2577,8 +2618,14 @@ impl Game<'_> { pickups::Taken::Item(Box::new(item.clone())), pickups::Collector::Player, ); + // handleTakeItemEntity: the random pitch of a full pickup. + let pitch = ((self.world.random.next_float() + - self.world.random.next_float()) + * 0.7 + + 1.0) + * 2.0; self.audio - .play("minecraft:entity.item.pickup", Some(feet), 0.2, 1.0); + .play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch); } item.stack = rest; } @@ -2635,6 +2682,7 @@ impl Game<'_> { ) }) .collect(); + let cart_signals = self.cart_signals(); let arrow_boxes = self .creatures .projectiles @@ -2677,6 +2725,7 @@ impl Game<'_> { }; let mut level = GameLevel { cart_boxes, + cart_signals, arrow_boxes, blockers, world: &mut self.world, @@ -3073,6 +3122,34 @@ impl Game<'_> { pos, data, } => self.composter_fill_event(pos, data == 1), + // LevelEventHandler 1504: the stalactite's drip particle. + LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos), + // LevelEventHandler 2006: the dragon fireball's burst. + LevelEvent::LevelEvent { + id: 2006, + pos, + data, + } => self.dragon_fireball_burst(pos, data), + // LevelEventHandler 2009: a wet sponge drying. + LevelEvent::LevelEvent { id: 2009, pos, .. } => { + self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud) + } + // LevelEventHandler 2012: a turtle egg laid. + LevelEvent::LevelEvent { + id: 2012, + pos, + data, + } => self.turtle_egg_particles(pos, data), + // LevelEventHandler 2014: a straw bed destroyed, with no sound. + LevelEvent::LevelEvent { + id: 2014, + pos, + data, + } => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)), + // LevelEventHandler 3009: a sniffer egg cracking. + LevelEvent::LevelEvent { id: 3009, pos, .. } => { + self.sniffer_egg_crack_particles(pos) + } LevelEvent::LevelEvent { id: id @ 3011..=3021, pos, @@ -3088,6 +3165,16 @@ impl Game<'_> { 1501 => Some(("minecraft:block.lava.extinguish", 0.5)), 1009 => Some(("minecraft:block.fire.extinguish", 0.5)), 1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)), + 1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)), + 1034 => Some(("minecraft:block.chorus_flower.death", 1.0)), + 1046 => Some(( + "minecraft:block.pointed_dripstone.drip_lava_into_cauldron", + 2.0, + )), + 1047 => Some(( + "minecraft:block.pointed_dripstone.drip_water_into_cauldron", + 2.0, + )), // LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break). 1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)), 1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)), @@ -3109,6 +3196,13 @@ impl Game<'_> { - self.world.random.next_float()) * 0.8 } + // LevelEventHandler draws these from the client + // level's random, before 1501's and 1502's smoke. + 1501 | 1502 => { + let r = self.particles.level_random(); + 2.6 + (r.next_float() - r.next_float()) * 0.8 + } + 1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9, 1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => { self.world.random.next_float() * 0.1 + 0.9 } @@ -3127,6 +3221,15 @@ impl Game<'_> { self.audio.play(name, c, volume, pitch); } } + // LevelEventHandler 1501 and 1502 draw their smoke after the sound. + match id { + 1501 => self.puffs_above_block( + pos, + horizon_client::particles::ParticleType::LargeSmoke, + ), + 1502 => self.torch_burnout_smoke(pos), + _ => {} + } } LevelEvent::Sound { pos, @@ -3193,6 +3296,8 @@ impl Game<'_> { // Popped as the behaviour emits it (GameLevel::emit). PlayerEffect::DropItem(_) => {} PlayerEffect::EatCake => { + // CakeBlock.eat + self.stats.custom("eat_cake_slice", 1); if let Some(p) = self.player.as_mut() { let food = &mut p.health.food; food.food = (food.food + 2).min(20); @@ -3326,11 +3431,18 @@ impl Game<'_> { self.open_command_block(key); return; } + // SignBlock.useWithoutItem: the text editor, not a container. + if horizon_client::signs::sign_kind(&name).is_some() { + self.use_sign_empty_hand(pos); + return; + } if &*name == "minecraft:crafting_table" { self.craft_size = 3; self.craft_table = Some(pos); } else if &*name == "minecraft:ender_chest" { self.open_ender_chest(key); + // EnderChestBlock.useWithoutItem + self.stats.custom("open_enderchest", 1); self.container_opener_event(key, true); let at = [ f64::from(key.0) + 0.5, @@ -3722,6 +3834,21 @@ impl Game<'_> { { self.hive_broken_by_player(pos, hive, honey, facing); } + // InfestedBlock.spawnAfterBreak: its silverfish comes out unless the + // tool has silk touch (#prevents_infested_spawns) or blocks don't drop. + if facts.name.starts_with("minecraft:infested_") + && !silk + && self.ui.rules.bool("block_drops") + { + let at = [ + f64::from(pos[0]) + 0.5, + f64::from(pos[1]), + f64::from(pos[2]) + 0.5, + ]; + if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) { + self.mob_poof(id); + } + } // SpawnerBlock.spawnAfterBreak pops its experience whatever the tool. let xp = if silk && &*facts.name != "minecraft:spawner" { 0 @@ -3843,6 +3970,10 @@ impl Game<'_> { self.swing_arm(); return Ok(true); } + if self.use_sign_applicator(hit.pos, held.as_ref()) { + self.swing_arm(); + return Ok(true); + } if self.use_jukebox(hit.pos, held.as_ref()) { self.swing_arm(); return Ok(true); @@ -3902,6 +4033,20 @@ impl Game<'_> { ) }); self.process_level_events()?; + // NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung). + if result == horizon_world::level::InteractionResult::Success { + let name = self + .res + .facts() + .get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2])) + .name + .clone(); + match &*name { + "minecraft:note_block" => self.stats.custom("tune_noteblock", 1), + "minecraft:bell" => self.stats.custom("bell_ring", 1), + _ => {} + } + } if result.consumes_action() { self.swing_arm(); return Ok(true); @@ -4792,6 +4937,10 @@ impl Game<'_> { }; self.audio.play(name, Some(feet), volume, pitch); } + // Player.causeFallDamage: a fall of 2 blocks or more counts. + MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) => { + self.stats.custom("fall_one_cm", cm) + } // LivingEntity.checkFallDamage: the block's crumbs where the fall landed. MotionEvent::Fall(horizon_client::landing::FallEvent::Particles { state, @@ -7934,13 +8083,15 @@ impl Game<'_> { } /// The visual and sound part of `Player.doSweepAttack` after a successful hit. pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) { - self.particles.add( - &horizon_client::particles::ParticleOptions::simple( - horizon_client::particles::ParticleType::SweepAttack, - ), - at, - [0.0; 3], + let sweep = horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::SweepAttack, ); + self.particles.add(&sweep, at, [0.0; 3]); + // Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`: + // the offset is the one from the player to the particle. + let origin = from.unwrap_or(at); + let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]]; + self.lan_send_particles(&sweep, at, spread, 0.0, 0); self.audio .play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0); } @@ -8095,11 +8246,6 @@ impl Game<'_> { let sweep = self.sweep_attack_position(scale, crit); let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale); self.attack_ticks = 0; - if let Some(p) = self.player.as_mut() - && !self.creative - { - p.cause_food_exhaustion(0.1); - } let player = self.player.as_ref(); let from = player.map(|p| [p.position.x, p.position.y, p.position.z]); if knockback_attack { @@ -8179,6 +8325,14 @@ impl Game<'_> { 0.2, (actual * 0.5) as u32, ); + // The players a server serves are sent them too. + self.lan_send_particles( + &indicator, + [feet[0], feet[1] + height * 0.5, feet[2]], + [0.1, 0.0, 0.1], + 0.2, + (actual * 0.5) as u32, + ); } self.last_hurt_by_player .insert(self.mobs[index].id, self.lan_acting); @@ -8209,6 +8363,13 @@ impl Game<'_> { } } self.weapon_hit(index); + // Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt + // (inside `if (wasHurt)`), after damageStatsAndHearts. + if let Some(p) = self.player.as_mut() + && !self.creative + { + p.cause_food_exhaustion(0.1); + } } else { self.audio .play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0); @@ -8772,6 +8933,10 @@ impl Game<'_> { } self.lan_close(false); self.save_game()?; + if let Some(mut remote) = self.remote.take() { + // Minecraft.disconnect closes the connection, so the host sees a clean leave. + remote.session.disconnect(); + } self.saved_for_exit = true; Ok(()) } @@ -9053,6 +9218,9 @@ impl Game<'_> { if result.outcome == Outcome::Pass { return Ok(false); } + if let Some(stat) = result.stat { + self.stats.custom(stat, 1); + } if let Some(block) = result.block && let Ok(new) = registry.resolve(&block) { @@ -9162,6 +9330,8 @@ impl Game<'_> { else { return Ok(false); }; + // BlockTransformer.transformBlock: ITEM_USED_ON_BLOCK before the swap. + self.trigger_block("minecraft:item_used_on_block", pos, stack); self.level_set(pos, new)?; // BlockTransformer.TransformParticle.send. self.transform_particle(&particle, pos); @@ -9434,6 +9604,8 @@ impl Game<'_> { if let Some(waxed) = old.and_then(|old| self.res.transformers().waxed(registry, &old)) && let Ok(state) = registry.resolve(&waxed) { + // HoneycombItem.useOn: ITEM_USED_ON_BLOCK before the swap. + self.trigger_block("minecraft:item_used_on_block", hit.pos, stack); self.level_set(hit.pos, state)?; self.level_event_at(3003, hit.pos, 0); self.audio.play( @@ -10884,6 +11056,21 @@ impl Game<'_> { } return Ok(false); } + // A server pack's reload covers the frame with its progress + // (`LoadingOverlay`) until `tick_server_packs` applies it. + if let Some(reload) = &self.server_packs.reload { + if let Some(renderer) = &mut self.renderer { + let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]); + horizon_client::level_loading::draw_progress( + &mut hud, + &self.gui.atlas, + reload.progress.line(), + reload.progress.fraction(), + ); + return renderer.render(&loading_frame(&hud)); + } + return Ok(false); + } let Some(size) = self .renderer .as_ref() @@ -11971,6 +12158,19 @@ impl ApplicationHandler for Game<'_> { { return; } + // The pack reload's progress overlay takes no input + // (`LoadingOverlay`): `start_pack_reload` released what was held. + if self.server_packs.reload.is_some() + && !matches!( + &event, + WindowEvent::CloseRequested + | WindowEvent::Focused(_) + | WindowEvent::Resized(_) + | WindowEvent::RedrawRequested + ) + { + return; + } if self.pending_save.is_some() { match event { WindowEvent::CloseRequested => { @@ -12773,6 +12973,16 @@ impl ApplicationHandler for Game<'_> { } } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if horizon_threads::stop_requests() > 0 + && self.pending_save.as_ref().is_none_or(|save| !save.quit) + { + // SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close. + if let Err(error) = self.begin_closing_save(true) { + self.error = Some(error); + event_loop.exit(); + return; + } + } if self.pending_save.is_some() { match self.poll_closing_save() { Ok(true) => event_loop.exit(), @@ -13468,7 +13678,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [ KeyCode::Numpad9, ]; -const STAGES: u32 = 10; const STAGE_LINES: [&str; 10] = [ "Loading menu", "Loading assets", @@ -13484,19 +13693,28 @@ const STAGE_LINES: [&str; 10] = [ /// The background loader's checkpoint, read by the loading screen each frame. #[derive(Clone)] -struct Progress(Arc); +struct Progress { + value: Arc, + lines: &'static [&'static str], +} impl Progress { fn new() -> Self { - Self(Arc::new(AtomicU32::new(0))) + Self::with(&STAGE_LINES) + } + fn with(lines: &'static [&'static str]) -> Self { + Self { + value: Arc::new(AtomicU32::new(0)), + lines, + } } fn set(&self, stage: u32) { - self.0.store(stage, Ordering::Relaxed); + self.value.store(stage, Ordering::Relaxed); } fn fraction(&self) -> f32 { - self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32 + self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32 } fn line(&self) -> &'static str { - STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)] + self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)] } } @@ -14263,6 +14481,11 @@ impl Loading { } } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if horizon_threads::stop_requests() > 0 { + // No world is open yet: nothing to save. + event_loop.exit(); + return; + } if let Err(error) = self.poll_menu_resources() { self.error = Some(error); event_loop.exit(); @@ -14540,6 +14763,9 @@ pub fn run() -> Result<()> { { horizon_client::heartbeat::start(); } + if options.execution_role.is_client() { + horizon_threads::catch_stop_signals(); + } let started = Instant::now(); horizon_client::spans::enable(options.spikes.is_some()); let vanilla_root = options.resources.clone(); diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 0f76d0ca9..f7bafd767 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -708,6 +708,27 @@ impl Game<'_> { let it = item_view(stack); self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w)); } + /// `TradeTrigger`: the villager and the item the trade gave. + pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) { + let me = self.player_entity(); + let villager = villager.clone(); + let it = item_view(stack); + self.trigger("minecraft:villager_trade", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + let vctx = adv::Entities { + this: Some(&villager), + ..pctx + }; + adv::conditions(&c["player"], &pctx, w) + && adv::conditions(&c["villager"], &vctx, w) + && adv::item(&c["item"], &it, w) + }); + } /// `EnterBlockTrigger`: the block, its state properties and the player. pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) { let Some(definition) = self.pack.assets.registry.state(state).cloned() else { diff --git a/crates/horizon-client/src/game/allays.rs b/crates/horizon-client/src/game/allays.rs index bed835663..dba4edfad 100644 --- a/crates/horizon-client/src/game/allays.rs +++ b/crates/horizon-client/src/game/allays.rs @@ -131,7 +131,8 @@ impl Game<'_> { let m = &mut self.mobs[i]; m.extra.ints[1] -= 1; m.extra.ints[3] = 60; - self.spawn_item(ItemStack::new(&item, 1), from, v, 40); + // ItemEntity.setDefaultPickUpDelay. + self.spawn_item(ItemStack::new(&item, 1), from, v, 10); if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 { const PITCHES: [f32; 16] = [ 0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5, diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 94f7661b7..c392f3ea6 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -789,6 +789,11 @@ impl Game<'_> { pub(super) fn sync_rules(&mut self) { self.world.health_rules.natural_regeneration = self.ui.rules.bool("natural_health_regeneration"); + // Player.isInvulnerableTo: the damage-type rules. + self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage"); + self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage"); + self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage"); + self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage"); use horizon_world::food::Difficulty; self.world.health_rules.difficulty = match self.ui.difficulty { 0 => Difficulty::Peaceful, diff --git a/crates/horizon-client/src/game/consume.rs b/crates/horizon-client/src/game/consume.rs index dc187d207..e5fae08ca 100644 --- a/crates/horizon-client/src/game/consume.rs +++ b/crates/horizon-client/src/game/consume.rs @@ -67,6 +67,17 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.burp", self.feet(), 0.5, pitch); } + // SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is + // added as a `MobEffectInstance(effect, duration)` (amplifier 0). + if let Some(data) = stack.extra.as_deref() { + for (id, duration) in &data.stew_effects { + if let Some(def) = horizon_client::brewing::effect(id) { + self.add_player_effect(horizon_client::effects::EffectInstance::new( + def.id, *duration, 0, + )); + } + } + } for effect in &consumable.effects { self.apply_consume_effect(effect); } diff --git a/crates/horizon-client/src/game/copper_golems.rs b/crates/horizon-client/src/game/copper_golems.rs index 2f98e1c7e..db4e03b0b 100644 --- a/crates/horizon-client/src/game/copper_golems.rs +++ b/crates/horizon-client/src/game/copper_golems.rs @@ -44,13 +44,13 @@ impl Game<'_> { slot: usize, stack: Option, ) { - if let Some(e) = self - .world - .block_entities - .get_mut(&(chest[0], chest[1], chest[2])) + let key = (chest[0], chest[1], chest[2]); + if let Some(e) = self.world.block_entities.get_mut(&key) && let Some(s) = e.slots_mut().get_mut(slot) { *s = stack; + // TransportItemsBetweenContainers calls Container.setChanged. + self.container_changed(key); } } /// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 221af37f6..1e1b3456d 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -46,10 +46,22 @@ pub(super) struct Creatures { pub projectiles: Vec, /// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs). pub wind_bursts: Vec, + /// Explosion packets from a guest's turn, waiting for it to end + /// (centre, small, radius, blocks hit; game/lan_particles.rs). + pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>, /// Ticks left of the elder guardian's apparition (MobAppearanceParticle). pub elder_apparition: i32, /// The wandering trader spawner's clock. pub trader: super::wandering_trader::Spawner, + /// Particles from a guest's turn, waiting for it to end + /// (`lan_send_particles`: options, centre, spread, speed, count). + pub lan_particles: Vec<( + horizon_client::particles::ParticleOptions, + [f64; 3], + [f64; 3], + f64, + u32, + )>, } pub(super) struct ProjWorld<'a, 'w> { pub w: &'a MobWorld<'w>, @@ -198,6 +210,7 @@ impl Game<'_> { uuid: self.level.player_uuid.unwrap_or_default(), allies: std::sync::Arc::new(self.player_allies()), remote: false, + passenger: self.riding.is_some(), }) } /// Biome facts at a position for finalizeSpawn and variant rules. @@ -2125,6 +2138,7 @@ impl Game<'_> { _ => self.projectile_hit(&p, hit)?, } } + self.flush_lan_explosions(); self.flush_wind_bursts() } /// `projectile_hit` for a projectile the guest `conn` shot, run as them; @@ -2891,6 +2905,14 @@ impl Game<'_> { self.swing_arm(); return Ok(true); } + if self.interact_armadillo(index, &item) { + if let Some(stack) = &held { + let target = self.mob_entity(&self.mobs[index]); + self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target); + } + self.swing_arm(); + return Ok(true); + } if self.interact_copper_golem(index, &item) { self.swing_arm(); return Ok(true); @@ -2946,6 +2968,16 @@ impl Game<'_> { consume = true; sound = Some("entity.cow.milk"); } + // Goat.mobInteract: a bucket on an adult gives milk. + Species::Goat if item == "minecraft:bucket" && !m.is_baby() => { + give = Some("minecraft:milk_bucket"); + consume = true; + sound = Some(if m.extra.screaming { + "entity.goat.screaming.milk" + } else { + "entity.goat.milk" + }); + } Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => { give = Some("minecraft:mushroom_stew"); consume = true; @@ -3206,6 +3238,19 @@ impl Game<'_> { self.trigger_entity("minecraft:tame_animal", "entity", &view); } } + // Tadpole.feed: frog food ages it by a tenth of the time left to + // grow into a frog (it grows on its next tick once past 24000). + Species::Tadpole if is_food && !m.extra.age_locked => { + consume = true; + let left = 24_000 - m.extra.age; + m.extra.age += ((left / 20) as f32 * 0.1) as i32 * 20; + // One happy villager particle at rest about the body. + self.particles_around_mob( + self.mobs[index].id, + "happy_villager", + horizon_client::particles::Burst::HORSE_EATING, + ); + } // HappyGhast.canFallInLove: never, so only a ghastling takes food. _ if is_food && m.kind.flags.animal @@ -3293,6 +3338,50 @@ impl Game<'_> { } Ok(true) } + /// `Armadillo.mobInteract`: a brush brushes a scute off an adult + /// (`brushOffScute`) and loses 16 durability. + fn interact_armadillo(&mut self, index: usize, item: &str) -> bool { + let m = &self.mobs[index]; + if item != "minecraft:brush" + || m.species() != Species::Armadillo + || !m.alive() + || m.is_baby() + { + return false; + } + let (id, at) = (m.id, m.position); + let feet = [at.x, at.y, at.z]; + let tool = self.held().cloned(); + let table = "minecraft:brush/armadillo"; + for d in self.sequence_loot(table, table, tool.as_ref()) { + self.spawn_at_location(at, 0.0, d); + } + self.audio + .play("minecraft:entity.armadillo.brush", Some(feet), 1.0, 1.0); + let ctx = self.who_context(Some(Who::Mob(id)), None); + self.post_game_event("minecraft:entity_interact", feet, ctx); + // ItemStack.hurtAndBreak(16). + if !self.creative + && let Some(stack) = tool + { + let max = self + .res + .items() + .get(&stack.item) + .map_or(0, |i| i.max_damage); + if max > 0 { + let loss = self.durability_loss(&stack, 16); + let slot = self.held_slot(); + if let Some(held) = self.stack_in_mut(slot).as_mut() { + held.damage += loss; + if held.damage >= max { + *self.stack_in_mut(slot) = None; + } + } + } + } + true + } pub(super) fn damage_held_tool(&mut self) { if self.creative { return; diff --git a/crates/horizon-client/src/game/deflect.rs b/crates/horizon-client/src/game/deflect.rs index a08de87b3..088374a34 100644 --- a/crates/horizon-client/src/game/deflect.rs +++ b/crates/horizon-client/src/game/deflect.rs @@ -120,6 +120,11 @@ impl Game<'_> { p.guest = guest; // Not into the one who turned it on its way out. p.left_owner = false; + // `AbstractHurtingProjectile.onDeflection` by an attack, and the + // `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn + // at once, not at the fireball's next update ten ticks later. + p.acceleration = 0.1; + p.needs_sync = true; self.reset_attack_strength(); self.swing_arm(); let from = self diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index 4f6cdb0b3..bd324d437 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -291,11 +291,11 @@ pub(super) enum FightWho { } /// A player in the End as the dragon sees it. #[derive(Clone, Copy, Debug)] -struct FightPlayer { - who: FightWho, - position: Vec3, - bounds: Aabb, - alive: bool, +pub(super) struct FightPlayer { + pub(super) who: FightWho, + pub(super) position: Vec3, + pub(super) bounds: Aabb, + pub(super) alive: bool, creative: bool, spectator: bool, } @@ -693,7 +693,7 @@ impl Game<'_> { } /// The players in the level the dragon sees (`level.players()`): the /// window's own, then every guest in the level. - fn fight_players(&self) -> Vec { + pub(super) fn fight_players(&self) -> Vec { let mut out = Vec::new(); if let Some(p) = self.player.as_ref() { out.push(FightPlayer { @@ -722,7 +722,7 @@ impl Game<'_> { } /// Run `f` as the fight's player `who` (a guest's turn, its knockback /// sent to its client). - fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) { + pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) { match who { FightWho::Host => f(self), FightWho::Guest(conn) => { @@ -1568,6 +1568,7 @@ impl Game<'_> { age: 0, duration: 200, from_dragon: true, + potion: None, }); } } @@ -2080,12 +2081,18 @@ impl Game<'_> { age: 0, duration: 600, from_dragon: false, + potion: None, }); - self.audio.play( - "minecraft:entity.dragon_fireball.explode", - Some([next.x, next.y, next.z]), - 1.0, - 1.0, + // DragonFireball.onHit: level event 2006 (the burst of dragon + // breath and the explosion sound) at its block position. + self.level_event_at( + 2006, + [ + next.x.floor() as i32, + next.y.floor() as i32, + next.z.floor() as i32, + ], + 1, ); return false; } @@ -2112,6 +2119,10 @@ impl Game<'_> { let mut harmed: Vec<(FightWho, f32)> = Vec::new(); let mut clouds = std::mem::take(&mut self.breath_clouds); clouds.retain_mut(|c| { + // Lingering potions' clouds tick with the potions. + if c.potion.is_some() { + return true; + } c.age += 1; if c.age >= c.duration { return false; @@ -2259,8 +2270,22 @@ pub(super) struct Breath { pub(super) at: [f64; 3], pub(super) radius: f32, pub(super) age: i32, - duration: i32, - from_dragon: bool, + pub(super) duration: i32, + pub(super) from_dragon: bool, + /// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies. + pub(super) potion: Option, +} +impl Breath { + /// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a + /// lingering potion's). + pub(super) fn waiting(&self) -> bool { + self.age + < if self.potion.is_some() { + super::potions::LINGERING_WAIT + } else { + 20 + } + } } impl Game<'_> { /// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`. diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index b308b9f1d..c42dad427 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -433,6 +433,8 @@ impl Game<'_> { let options = ParticleOptions::simple(kind); self.particles .send_particles(&options, at, spread, speed, count); + // The players a server serves are sent them too. + self.lan_send_particles(&options, at, spread, speed, count); } /// `FishingHook.tick`. pub(super) fn tick_fishing(&mut self) { diff --git a/crates/horizon-client/src/game/jukebox.rs b/crates/horizon-client/src/game/jukebox.rs index c3ed75ebb..87f91895e 100644 --- a/crates/horizon-client/src/game/jukebox.rs +++ b/crates/horizon-client/src/game/jukebox.rs @@ -79,6 +79,7 @@ impl Game<'_> { if !self.creative { self.consume_held(); } + self.stats.custom("play_record", 1); self.world.block_entities.insert( (pos[0], pos[1], pos[2]), BlockEntity::Jukebox(Jukebox { @@ -90,6 +91,8 @@ impl Game<'_> { self.set_has_record(pos, true); self.start_song(pos, &song); self.jukebox_changed(pos); + // ServerPlayerGameMode.useItemOn: the action was consumed. + self.trigger_block("minecraft:item_used_on_block", pos, stack); true } /// Level event 1010: the song from the jukebox, and the "Now Playing" overlay. diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index a0f99bfca..9ea4fe7c9 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1030,6 +1030,9 @@ impl Game<'_> { for m in &mut self.mobs { m.needs_sync = false; } + for p in &mut self.creatures.projectiles { + p.needs_sync = false; + } // `runBlockEvents`' broadcast precedes the tick's block changes // (`ChunkHolder.broadcastChanges` in `tick_level` below): the // guest re-runs a piston's `triggerEvent` while its level still @@ -1190,8 +1193,11 @@ impl Game<'_> { drop(holding); lan::flush_all(host.hub.guests.iter().map(|g| &g.sender)); self.lan = Some(host); - // A mace's wind burst from a guest's attack. + // A blast from a guest's attack, and a mace's wind burst. + self.flush_lan_explosions(); let _ = self.flush_wind_bursts(); + // Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes). + self.flush_lan_particles(); } fn lan_guest_joined( diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index ef177d26d..f9a61f280 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -267,8 +267,10 @@ impl Game<'_> { } let mut play = host.play.remove(&conn).unwrap_or_default(); if fresh { - // A new body (joined, respawned, changed level): nothing on it yet. + // A new body (joined, respawned, changed level): nothing on it yet, + // its effects' modifiers and armor protection included. play.equipped = None; + play.potions = Default::default(); play.sculk = Default::default(); } let g = &mut host.hub.guests[i]; @@ -405,6 +407,12 @@ impl Game<'_> { self.apply_equipment(); play.equipped = Some(equipped); } + // The player's effects are on the new body before anything it does + // (the movement that brought it, a landing): vanilla keeps the same + // entity through a level change, with its attributes and effects. + if fresh { + self.apply_effect_status(); + } self.update_totem_ready(); Some(GuestSwap { conn, @@ -1541,9 +1549,13 @@ impl Game<'_> { if let Some(victim) = Self::pvp_victim(host, conn, id) { if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike()) && self.pvp_land(host, victim, &strike) - && strike.stops_sprint() { - self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback()); + self.as_guest(host, conn, |g| { + g.pvp_exhaustion(); + if strike.stops_sprint() { + g.stop_sprint_after_extra_knockback(); + } + }); } return; } @@ -1848,10 +1860,6 @@ impl Game<'_> { }; xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR); (g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total); - // A new body: nothing given to the old one carries over. - if let Some(play) = host.play.get_mut(&conn) { - play.potions = Default::default(); - } let clock_rate = self.network_clock_rate(); let ctx = TickCtx { world: &mut self.world, @@ -2336,6 +2344,13 @@ impl Game<'_> { // ServerEntity.sendChanges sends it as it is after this travel. let marked = std::mem::take(&mut player.health.sync_velocity); let velocity = marked.then_some(player.velocity); + // Player.causeFallDamage: a fall of 2 blocks or more counts. + for event in &events { + if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event + { + game.stats.custom("fall_one_cm", *cm); + } + } game.record_combat(&events); game.wear_armor(&events); (events, velocity) diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 665710be2..f5e35631d 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -342,6 +342,7 @@ impl Game<'_> { let mut e = state(p.id, kind, at(p.position), p.yaw, false); e.pitch = p.pitch; e.velocity = at(p.velocity); + e.needs_sync = p.needs_sync; // Projectile.getAddEntityPacket: the owner's id. e.data = match p.guest.and_then(|conn| hub.guest(conn)) { Some(g) => g.entity_id, @@ -466,7 +467,7 @@ impl Game<'_> { } out.push(e); } - // The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS, + // The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS, // DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the // client draws them from these (`tick_remote_clouds`). let breath = self.network_particle( @@ -484,10 +485,14 @@ impl Game<'_> { for b in self.breath_clouds.iter().filter(|b| b.id != 0) { let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false); e.meta.push((8, serializer::FLOAT, Value::Float(b.radius))); - if b.age < 20 { + if b.waiting() { e.meta.push((9, serializer::BOOLEAN, Value::Bool(true))); } - if let Some(p) = &breath { + let particle = match &b.potion { + Some(p) => self.network_particle(&p.particle(), None), + None => breath.clone(), + }; + if let Some(p) = &particle { e.meta .push((10, serializer::PARTICLE, Value::Particle(p.clone()))); } diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index db042be43..973e645d0 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -369,6 +369,7 @@ mod tests { uuid: [0; 4], allies: Default::default(), remote: false, + passenger: false, } } diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 648cef962..5554d6f21 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -318,16 +318,86 @@ impl Game<'_> { } } + /// `ServerLevel.sendParticles` for the players a server serves: one + /// `ClientboundLevelParticlesPacket` to each player in the level whose + /// block is within 32 blocks of `at`. The window's own player is not + /// sent it (the caller draws it in this window). While the host holds + /// the players (a guest's turn through `as_guest`, a host's hit on a + /// guest) the packet waits for the turn to end so that it reaches every + /// player, the guest too. + pub(super) fn lan_send_particles( + &mut self, + options: &ParticleOptions, + at: [f64; 3], + spread: [f64; 3], + speed: f64, + count: u32, + ) { + if self.lan.is_none() { + // The server is running (its sounds are captured for the guests). + if self.audio.capture.is_some() { + self.creatures + .lan_particles + .push((options.clone(), at, spread, speed, count)); + } + return; + } + let Some(particle) = self.network_particle(options, None) else { + return; + }; + let Some(host) = self.lan.as_ref() else { + return; + }; + let body = C::LevelParticles(play::LevelParticles { + particle, + override_limiter: false, + always_show: false, + pos: at, + dist: spread.map(|d| d as f32), + max_speed: [speed as f32; 3], + count: count as i32, + randomization: 0, + }) + .encode(); + for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) { + // BlockPos.closerToCenterThan(at, 32): from the centre of the + // player's block. + let d: f64 = (0..3) + .map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2)) + .sum(); + if d < 32.0 * 32.0 { + g.sender.send(body.clone()); + } + } + } + + /// The particles guests' turns put off. + pub(super) fn flush_lan_particles(&mut self) { + for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles) + { + self.lan_send_particles(&options, at, spread, speed, count); + } + } + /// `ServerLevel.explode`'s packet to each player within 64 blocks. The /// sound and the push reach them on their own (the level's sounds, the /// motion a hurt player is sent), so the packet carries neither. pub(super) fn lan_explode( - &self, + &mut self, center: [f64; 3], small: bool, radius: f32, block_count: usize, ) { + // In a guest's turn (their hit on an end crystal or a minecart) the + // host holds the players (`as_guest`): the packet waits for the turn + // to end so that it reaches every player, the guest too. + if self.lan_acting.is_some() && self.lan.is_none() { + self.creatures + .lan_explosions + .push((center, small, radius, block_count)); + return; + } let emitter = if small { "minecraft:explosion" } else { @@ -341,6 +411,15 @@ impl Game<'_> { } } + /// The explosions guests' turns put off. + pub(super) fn flush_lan_explosions(&mut self) { + for (center, small, radius, block_count) in + std::mem::take(&mut self.creatures.lan_explosions) + { + self.lan_explode(center, small, radius, block_count); + } + } + /// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters, /// no block particles, and for each player it pushed the push /// (`ServerExplosion.hitPlayers`), which only that client applies, as diff --git a/crates/horizon-client/src/game/lan_pvp.rs b/crates/horizon-client/src/game/lan_pvp.rs index 65520221a..aa969305f 100644 --- a/crates/horizon-client/src/game/lan_pvp.rs +++ b/crates/horizon-client/src/game/lan_pvp.rs @@ -86,9 +86,11 @@ impl Game<'_> { }; if let Some(strike) = self.pvp_strike() && self.pvp_land(&mut host, Victim::Guest(conn), &strike) - && strike.stops_sprint() { - self.stop_sprint_after_extra_knockback(); + self.pvp_exhaustion(); + if strike.stops_sprint() { + self.stop_sprint_after_extra_knockback(); + } } self.lan = Some(host); true @@ -106,8 +108,18 @@ impl Game<'_> { .map(|g| Victim::Guest(g.conn)) } + /// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker + /// (the host, or a guest swapped in) only once the hit hurt the victim. + pub(super) fn pvp_exhaustion(&mut self) { + if let Some(p) = self.player.as_mut() + && !self.creative + { + p.cause_food_exhaustion(0.1); + } + } + /// The attacker's side of `Player.attack` (the host, or a guest swapped - /// in): the damage, the charge reset, the exhaustion and the swing. + /// in): the damage, the charge reset and the swing. pub(super) fn pvp_strike(&mut self) -> Option { let p = self.player.as_ref()?; let from = [p.position.x, p.position.y, p.position.z]; @@ -116,11 +128,6 @@ impl Game<'_> { let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale); let (xd, zd) = self.extra_knockback_direction(); self.attack_ticks = 0; - if let Some(p) = self.player.as_mut() - && !self.creative - { - p.cause_food_exhaustion(0.1); - } self.swing_arm(); Some(Strike { attacker: self.lan_acting, diff --git a/crates/horizon-client/src/game/lan_trading.rs b/crates/horizon-client/src/game/lan_trading.rs index 7beeeba2f..0177a24b1 100644 --- a/crates/horizon-client/src/game/lan_trading.rs +++ b/crates/horizon-client/src/game/lan_trading.rs @@ -277,7 +277,12 @@ impl Game<'_> { g.carried = menu.carried.take(); } self.register_exploration_maps(made); - gm.merchant.session = self.merchant_apply(session, gm.merchant.mob); + let (session, traded) = self.merchant_apply(session, gm.merchant.mob); + gm.merchant.session = session; + if !traded.is_empty() { + // The guest's own statistic and advancements. + self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded)); + } self.drop_guest_items(host, gi, dropped); self.lan_send_offers(host, conn, &mut gm, false); self.lan_send_merchant_content(host, conn, &mut gm); diff --git a/crates/horizon-client/src/game/level_particles.rs b/crates/horizon-client/src/game/level_particles.rs index dc815a39f..8a5c0bed8 100644 --- a/crates/horizon-client/src/game/level_particles.rs +++ b/crates/horizon-client/src/game/level_particles.rs @@ -1,12 +1,14 @@ //! Level events whose client handler (`LevelEventHandler`) spawns particles //! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a //! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax -//! off (3004) and scraping (3005). +//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon +//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009. use super::Game; use horizon_client::items::ItemStack; use horizon_client::particles::{self, ParticleOptions, ParticleType}; -use horizon_core::{position::BlockPos, random::RandomSource}; +use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource}; use horizon_world::level::LevelEvent; +use horizon_world::registry::BlockStateId; impl Game<'_> { pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) { @@ -72,24 +74,42 @@ impl Game<'_> { } else { // ParticleUtils.spawnParticleInBlock: up to the // block's shape (a full block when air). - let inside = self.world.block(at[0], at[1], at[2]); - let height = if facts.is_air(inside) { - 1.0 - } else { - facts - .get(inside) - .outline - .iter() - .map(|b| b[4]) - .fold(0.0, f64::max) - }; - (at, count, 0.5, height, true) + (at, count, 0.5, self.block_shape_top(at), true) } } None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false), None => return, }; - let options = ParticleOptions::simple(ParticleType::HappyVillager); + self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager); + } + /// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air. + fn block_shape_top(&self, at: [i32; 3]) -> f64 { + let facts = self.res.facts(); + let inside = self.world.block(at[0], at[1], at[2]); + if facts.is_air(inside) { + 1.0 + } else { + facts + .get(inside) + .outline + .iter() + .map(|b| b[4]) + .fold(0.0, f64::max) + } + } + /// `ParticleUtils.spawnParticles`: `count` slow particles spread over + /// `xz` blocks around `at` and `height` up, only above solid ground + /// unless floating ones are allowed. + fn spawn_particles( + &mut self, + at: [i32; 3], + count: i32, + xz: f64, + height: f64, + floating: bool, + ty: ParticleType, + ) { + let options = ParticleOptions::simple(ty); for _ in 0..count.max(0) { let r = self.particles.level_random(); let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02); @@ -108,6 +128,161 @@ impl Game<'_> { } } } + /// `LevelEventHandler` 2012 (a turtle egg laid on sand): + /// `ParticleUtils.spawnParticleInBlock` with happy villager sparks. + pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) { + let at = [pos.x, pos.y, pos.z]; + let height = self.block_shape_top(at); + self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager); + } + /// `LevelEventHandler` 3009 (a sniffer egg cracking on moss): + /// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`. + pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) { + let mut spawned = Vec::new(); + particles::on_block_faces_between( + self.particles.level_random(), + [pos.x, pos.y, pos.z], + 3, + 6, + |p, v| spawned.push((p, v)), + ); + let options = ParticleOptions::simple(ParticleType::EggCrack); + for (p, v) in spawned { + self.particles.add(&options, p, v); + } + } + /// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried): + /// eight puffs just above the block, drawn after the sound. + pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) { + let options = ParticleOptions::simple(ty); + for _ in 0..8 { + let r = self.particles.level_random(); + let x = f64::from(pos.x) + r.next_double(); + let z = f64::from(pos.z) + r.next_double(); + self.particles + .add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]); + } + } + /// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke + /// puffs inside the block, drawn after the sound. + pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) { + let options = ParticleOptions::simple(ParticleType::Smoke); + for _ in 0..5 { + let r = self.particles.level_random(); + let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2; + let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2; + let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2; + self.particles.add(&options, [x, y, z], [0.0; 3]); + } + } + /// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath + /// particles thrown outwards, then the explosion sound when `data` is 1. + pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) { + for _ in 0..200 { + let r = self.particles.level_random(); + let dist = r.next_float() * 4.0; + let angle = r.next_float() * std::f32::consts::TAU; + let vx = f64::from(trig::cos(f64::from(angle)) * dist); + let vy = 0.01 + r.next_double() * 0.5; + let vz = f64::from(trig::sin(f64::from(angle)) * dist); + let options = ParticleOptions { + scale: dist, + ..ParticleOptions::simple(ParticleType::DragonBreath) + }; + self.particles.add( + &options, + [ + f64::from(pos.x) + vx * 0.1, + f64::from(pos.y) + 0.3, + f64::from(pos.z) + vz * 0.1, + ], + [vx, vy, vz], + ); + } + if data == 1 { + let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5); + let pitch = self.world.random.next_float() * 0.1 + 0.9; + self.audio.play( + "minecraft:entity.dragon_fireball.explode", + Some(at), + 1.0, + pitch, + ); + } + } + /// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down. + fn is_stalactite(&self, state: BlockStateId) -> bool { + self.res.facts().get(state).has_class("SpeleothemBlock") + && self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("vertical_direction")) + .is_some_and(|d| d == "down") + } + /// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at + /// the tip `pos`. The fluid above the stalactite's root picks the drip; + /// with none, the dimension's default (lava in the Nether). + pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) { + let tip_state = self.world.block(pos.x, pos.y, pos.z); + if !self.is_stalactite(tip_state) { + return; + } + // findRootBlock: up through the same stalactite, at most 11 steps. + let tip_name = self.res.facts().get(tip_state).name.clone(); + let mut root = None; + for step in 1..11 { + let y = pos.y + step; + if !(horizon_client::chunk::MIN_Y + ..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT) + .contains(&y) + { + break; + } + let state = self.world.block(pos.x, y, pos.z); + if self.res.facts().get(state).name != tip_name { + root = Some(y); + break; + } + if !self.is_stalactite(state) { + break; + } + } + let Some(root_y) = root else { + return; + }; + let above = self.world.block(pos.x, root_y + 1, pos.z); + let above_facts = self.res.facts().get(above); + let nether = self.dim == super::Dim::Nether; + let fluid = if &*above_facts.name == "minecraft:mud" && !nether { + horizon_client::blocks::Fluid::Water + } else { + above_facts.fluid + }; + let ty = match fluid { + horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater, + horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava, + horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava, + horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater, + }; + let offset = self + .pack + .context + .tables + .offset(tip_state, pos.x, pos.z) + .unwrap_or([0.0; 3]); + // The tip shape starts 5/16 up; the particle sits a pixel below that. + self.particles.add( + &ParticleOptions::simple(ty), + [ + f64::from(pos.x) + 0.5 + offset[0], + f64::from(pos.y) + 5.0 / 16.0 - 0.0625, + f64::from(pos.z) + 0.5 + offset[2], + ], + [0.0; 3], + ); + } /// `ServerLevel.globalLevelEvent`: heard by every player on the server /// (an end portal opening, the dragon's death roar): here, and sent to /// every guest whatever their dimension. diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 8a3ac2745..0224f67e3 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -16,7 +16,7 @@ pub(super) struct Bolt { flashes: i32, seed: i64, visual_only: bool, - /// Mooshrooms switch colour once per bolt. + /// Mooshrooms and copper golems change once per bolt. converted: Vec, } impl Game<'_> { @@ -393,6 +393,17 @@ impl Game<'_> { continue; } Species::Creeper => m.extra.charged = true, + // CopperGolem.thunderHit (after Entity.thunderHit's fire and + // damage): once per bolt, an oxidized golem goes back one step + // and its weathering timer (or wax, -2) is reset to -1. + Species::CopperGolem if !b.converted.contains(&m.id) => { + b.converted.push(m.id); + if m.extra.variant > 0 { + m.extra.vector[0] = -1.0; + m.extra.variant = + horizon_client::mobs::copper_golem::weather_previous(m.extra.variant); + } + } _ => {} } // Entity.thunderHit: burning (8 seconds) and 5 lightning damage. diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 2ef4b8e8e..01a7c9497 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -9,7 +9,7 @@ use horizon_client::{ blocks::Fluid, collisions::SceneCollisions, containers::BlockEntity, - items::ItemStack, + items::{ItemStack, Items}, mesher::Vertex, mobs::{Part, anim::Poses}, particles::{ParticleOptions, ParticleType}, @@ -201,6 +201,17 @@ impl Minecart { fn horizontal(&self) -> f64 { self.velocity.x.hypot(self.velocity.z) } + /// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`. + fn container_signal(slots: &[Option], items: &Items) -> i32 { + let mut total = 0.0f32; + for s in slots.iter().flatten() { + let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64); + total += s.count as f32 / max as f32; + } + total /= slots.len().max(1) as f32; + // Mth.lerpDiscrete(total, 0, 15) + (total * 14.0).floor() as i32 + i32::from(total > 0.0) + } } pub(super) struct CartModels { pub models: HashMap, @@ -945,7 +956,7 @@ impl Game<'_> { } true } - /// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top. + /// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top. pub(super) fn dismount_cart(&mut self, i: usize) { self.riding = None; let c = &self.carts[i]; @@ -955,17 +966,25 @@ impl Game<'_> { c.position.z.floor() as i32, ); let mut target = None; + // DismountHelper.offsetsForDirection of the cart's facing + // (Direction.fromYRot of its yaw): right, left, back right, back left, + // forward right, forward left, back, forward. + let step = |q: i32| [(0, 1), (-1, 0), (0, -1), (1, 0)][(q & 3) as usize]; + let facing = (f64::from(c.yaw) / 90.0 + 0.5).floor() as i32; + let (fx, fz) = step(facing); + let (rx, rz) = (-fz, fx); + let offsets = [ + (rx, rz), + (-rx, -rz), + (rx - fx, rz - fz), + (-rx - fx, -rz - fz), + (fx + rx, fz + rz), + (fx - rx, fz - rz), + (-fx, -fz), + (fx, fz), + ]; 'search: for dy in [0, 1, -1] { - for (dx, dz) in [ - (1, 0), - (-1, 0), - (0, 1), - (0, -1), - (1, 1), - (-1, -1), - (1, -1), - (-1, 1), - ] { + for (dx, dz) in offsets { let (x, y, z) = (bx + dx, by + dy, bz + dz); let below = self.res.facts().get(self.world.block(x, y - 1, z)); let here = self.res.facts().get(self.world.block(x, y, z)); @@ -1049,6 +1068,22 @@ impl Game<'_> { } best } + /// Each cart's kind and comparator signal, for the detector rail + /// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open + /// counts the items in that menu. + pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> { + self.carts + .iter() + .map(|c| { + let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32); + let slots = match self.world.block_entities.get(&key) { + Some(BlockEntity::Chest(open)) => open, + _ => &c.items, + }; + (c.kind, Minecart::container_signal(slots, self.res.items())) + }) + .collect() + } /// `interact`: ride, open, fuel or ignite the cart under the crosshair. pub(super) fn interact_cart(&mut self) -> bool { let Some((i, d)) = self.cart_hit() else { @@ -1066,10 +1101,8 @@ impl Game<'_> { } self.riding = Some(self.carts[i].id); } + // AbstractMinecartContainer.interact: no sneak check. CartKind::Chest | CartKind::Hopper => { - if sneaking { - return false; - } let c = self.carts[i].clone(); let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32); self.world diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 89d7738f8..013b94de2 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -1,14 +1,23 @@ //! Resource pack selection bound to the game: the pack screen lists, and a //! full resource reload after a change, while playing. -use super::Game; +use super::{ + Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures, +}; use horizon_client::{ + assets::Assets, + gfx::PreparedTextures, hud::HudAtlas, lang, pack_screen::{self, Compat, PackInfo, Source as PackSource}, packs::{self, Pack, Position}, + streaming::WorkerContext, text::{Style, Text}, }; -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, + sync::Arc, +}; /// Where overlay roots are built (beside options.txt). pub fn cache_dir() -> PathBuf { @@ -98,7 +107,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec { infos } impl Game<'_> { - fn vanilla_root(&self) -> PathBuf { + pub(super) fn vanilla_root(&self) -> PathBuf { if self.ui.vanilla_root.as_os_str().is_empty() { self.options.resources.clone() } else { @@ -138,35 +147,36 @@ impl Game<'_> { self.ui.options.resource_error = Some(e.to_string()); } } - /// Reload from the selected packs and the server's. - pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { + /// The CPU half of the reload, safe on a worker (`PackReload` runs it): + /// `apply_staged_pack` finishes the GPU half and the swap. + pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result { let vanilla = self.vanilla_root(); if self.ui.vanilla_root.as_os_str().is_empty() { self.ui.vanilla_root = vanilla.clone(); } - let server = &self.server_packs.active; - let root = startup_root(&vanilla, &self.settings.resource_packs, server)?; - let root = - super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods); - self.reload_resources(&root) + stage_pack( + &vanilla, + &self.settings.resource_packs, + &self.server_packs.active, + &self.settings.mods.enabled, + &self.options.dev_mods, + &self.pack.assets, + &self.res, + &self.pack.context, + self.settings.force_unicode, + self.settings.japanese_glyphs, + &super::Progress::new(), + ) } - fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> { - let render = std::sync::Arc::new(self.pack.assets.reload(root)?); - let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?; - atlas.font.uniform = self.settings.force_unicode; - atlas.font.japanese = self.settings.japanese_glyphs; - let gui = super::UiResources { - atlas, - panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)), - }; - let pack = super::PackResources::load( + /// The render-thread half: the renderer on the new pack's textures, then + /// the swap (`ReloadInstance`'s application listeners). + pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> { + let StagedPack { root, - self.pack.assets.clone(), - render, - self.res.facts(), - self.pack.context.biomes.clone(), - self.pack.context.light.clone(), - )?; + gui, + pack, + textures, + } = staged; // Keep the working GPU resources, and hand its window surface over. if let Some(renderer) = &mut self.renderer { renderer.release_surface(); @@ -174,7 +184,17 @@ impl Game<'_> { let renderer = match self .window .clone() - .map(|window| self.renderer_for(window, &pack, &gui)) + .map(|window| { + build_renderer( + window, + self.options.offscreen, + &pack, + &gui, + self.renderer.as_ref(), + Some(textures), + self.settings.horizon.api, + ) + }) .transpose() { Ok(renderer) => renderer, @@ -190,18 +210,18 @@ impl Game<'_> { } }; // Everything loaded: switch over. - self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root); - self.armor_stand_assets.reload_trims(root); + self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root); + self.armor_stand_assets.reload_trims(&root); self.random_tick_sections.clear(); self.portals.sections.clear(); - self.options.resources = root.to_path_buf(); + self.options.resources = root.clone(); let context = pack.context.clone(); self.pack = pack; self.gui = gui; // The atlas's sprites moved: the particle engine re-reads every rect // and the packs' sprite sets, or every particle draws a stale region. self.particles - .reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent); + .reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent); horizon_client::ui_theme::reset_atlas(); // Villager hats come from the pack's texture metadata (shared data, swapped in place). *self @@ -210,11 +230,11 @@ impl Game<'_> { .villager_hats .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = - horizon_client::mobs::villager_hats::VillagerHats::load(root); - horizon_client::ui::load_metadata(root); - lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code())); + horizon_client::mobs::villager_hats::VillagerHats::load(&root); + horizon_client::ui::load_metadata(&root); + lang::set(lang::Language::load(&[&root], &lang::code())); self.refresh_remote_language(); - self.audio.reload(root); + self.audio.reload(&root); self.clear_lod(); self.streaming.reload_resources(&mut self.world, context); self.pending_uploads.clear(); @@ -229,4 +249,66 @@ impl Game<'_> { self.creative_screen.forget_items(); Ok(()) } + /// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then + /// applied (`PackReload` runs the same two phases across frames). + pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { + let staged = self.stage_selected_packs()?; + self.apply_staged_pack(staged) + } +} + +/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a +/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay +/// root, the models and sprites, the menu bundle, the pack's tables and its +/// CPU mip chains. +pub(super) fn stage_pack( + vanilla_root: &Path, + selected: &[String], + server: &[Pack], + mods_enabled: &BTreeSet, + dev_mods: &[PathBuf], + assets: &Arc, + res: &Resources, + context: &WorkerContext, + force_unicode: bool, + japanese: bool, + progress: &super::Progress, +) -> horizon_client::Result { + let root = startup_root(vanilla_root, selected, server)?; + let root = super::mods::content_root(&root, mods_enabled, dev_mods); + progress.set(1); + let render = Arc::new(assets.reload(&root)?); + let mut atlas = HudAtlas::build_over(&root, vanilla_root)?; + atlas.font.uniform = force_unicode; + atlas.font.japanese = japanese; + let gui = UiResources { + atlas, + panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)), + }; + progress.set(2); + let pack = PackResources::load( + &root, + assets.clone(), + render, + res.facts(), + context.biomes.clone(), + context.light.clone(), + )?; + let textures = prepare_renderer_textures(&pack); + progress.set(3); + Ok(StagedPack { + root, + gui, + pack, + textures, + }) +} + +/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes +/// it on the render thread). +pub(super) struct StagedPack { + pub root: PathBuf, + pub gui: UiResources, + pub pack: PackResources, + pub textures: PreparedTextures, } diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index caf49883b..2d5c6b05c 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -218,11 +218,20 @@ impl Game<'_> { _ => None, }) }); - // A living mount at the default scale and camera distance. + // Camera.setup: the living mount's scale and camera distance + // attributes (the happy ghast's is 8, the default 4). let mount = self .riding .and_then(|id| self.mobs.iter().find(|m| m.id == id)) - .map(|_| (1.0, 4.0)); + .map(|m| { + let attribute = |name: &str, default: f64| { + m.kind.attributes.get(name).copied().unwrap_or(default) + }; + ( + attribute("minecraft:scale", 1.0) as f32, + attribute("minecraft:camera_distance", 4.0), + ) + }); // AbstractBoat.positionRider turns a passenger by the boat's // deltaRotation after its yRotO was saved, and LocalPlayer.getViewYRot // interpolates a passenger's yaw: the boat's turn is spread over the diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index 5e4c70172..a87a15254 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -2,7 +2,12 @@ //! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`), //! filling glass bottles (`BottleItem`), the per-tick effect application, //! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`). -use super::{Game, Screen, item_draw::owner_at}; +use super::{ + Game, Screen, + dragon::{Breath, FightPlayer, FightWho}, + item_draw::owner_at, + remote_projectiles::cloud_client_tick, +}; use horizon_assets::item_model::DisplayContext; use horizon_client::{ block_entity_render::vertex_light, @@ -12,9 +17,13 @@ use horizon_client::{ hud_layout, hud_overlays, inventory_effects, items::ItemStack, mesher::Vertex, + particles::{ParticleOptions, ParticleType}, text::Text, }; -use horizon_core::{geometry::Vec3, random::RandomSource}; +use horizon_core::{ + geometry::{Aabb, Vec3}, + random::RandomSource, +}; /// A thrown splash or lingering potion in flight. #[derive(Clone, Debug)] @@ -31,6 +40,35 @@ pub(super) struct ThrownPotion { /// for a dispenser's). owner: Option, } +/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`. +pub(super) const LINGERING_WAIT: i32 = 10; +/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`. +const REAPPLICATION_DELAY: i32 = 20; +/// An entity a cloud has affected (`AreaEffectCloud.victims`). +#[derive(Clone, Copy, PartialEq)] +enum Victim { + Mob(u32), + Player(FightWho), +} +/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects +/// (at the lingering item's 0.25 duration scale) and who it has affected. +pub(super) struct PotionCloud { + effects: &'static [(&'static str, i32, i32)], + /// The potion's colour (`PotionContents.getColor`). + color: u32, + /// Each victim and the tick it can be affected again. + victims: Vec<(Victim, i32)>, + /// Who its instant damage names (`indirectMagic(cloud, owner)`): the + /// thrower, else the cloud. + source: Text, +} +impl PotionCloud { + /// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour. + pub(super) fn particle(&self) -> ParticleOptions { + let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0; + ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0]) + } +} /// A mob's effect ids carry the `minecraft:` namespace. fn mob_effect_id(id: &str) -> Option<&'static str> { use std::{collections::HashMap, sync::OnceLock}; @@ -48,6 +86,39 @@ fn mob_effect_id(id: &str) -> Option<&'static str> { .get(id) .copied() } +/// `LivingEntity.canBeAffected` with the mob overrides (`WitherBoss` and +/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison, +/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags +/// decide first, then the tag of mobs that ignore poison and regeneration. +fn mob_can_be_affected( + tags: &std::collections::HashMap>, + kind: &str, + effect: &str, +) -> bool { + let effect = effect.trim_start_matches("minecraft:"); + let kind = kind.trim_start_matches("minecraft:"); + let overridden = match kind { + "wither" | "wither_skeleton" => effect == "wither", + "spider" | "cave_spider" | "nautilus" | "zombie_nautilus" => effect == "poison", + "parched" => effect == "weakness", + _ => false, + }; + if overridden { + return false; + } + let in_tag = |tag: &str| { + tags.get(tag) + .is_some_and(|t| t.contains(&format!("minecraft:{kind}"))) + }; + if in_tag("minecraft:immune_to_infested") { + effect != "infested" + } else if in_tag("minecraft:immune_to_oozing") { + effect != "oozing" + } else { + !in_tag("minecraft:ignores_poison_and_regen") + || (effect != "regeneration" && effect != "poison") + } +} impl Game<'_> { /// A splash potion, lingering potion or experience bottle in flight (the /// player's throws and dispensers both end up here). @@ -391,6 +462,7 @@ impl Game<'_> { self.tick_mob_effects(); if running { self.tick_thrown_potions(); + self.tick_potion_clouds(); } } /// `LivingEntity.tickEffects` (server side) for `self.player`, the local @@ -406,8 +478,10 @@ impl Game<'_> { } // The omens act from inside the effect tick (BadOmenMobEffect, // RaidOmenMobEffect). + let absorption_empty = player.health.absorption() <= 0.0; let mut effects = std::mem::take(&mut self.potions.effects); - let (actions, raid_omen_added) = effects.tick_with(&mut |e| self.omen_tick(e)); + let (actions, raid_omen_added) = + effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty); self.potions.effects = effects; if raid_omen_added { self.raid_omen_added(); @@ -548,29 +622,176 @@ impl Game<'_> { let state = self.world .block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32); - if self.res.facts().get(state).full_collision - || self.mobs.iter().any(|m| { - m.alive() - && (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125 - && (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125 - && p.y >= m.position.y - && p.y <= m.position.y + m.kind.height - }) - { - hit = Some(p); + let mob = self.mobs.iter().find(|m| { + m.alive() + && (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125 + && (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125 + && p.y >= m.position.y + && p.y <= m.position.y + m.kind.height + }); + if self.res.facts().get(state).full_collision || mob.is_some() { + hit = Some((p, mob.map(|m| m.position))); break; } } - if let Some(p) = hit { - landed.push((t.stack.clone(), p, t.owner.clone())); + if let Some((p, mob)) = hit { + landed.push((t.stack.clone(), p, mob, t.owner.clone())); return false; } t.position = next; t.age < 1200 && t.position.y > -128.0 }); self.potions.thrown = thrown; - for (stack, at, owner) in landed { - self.splash(&stack, at, owner); + for (stack, at, mob, owner) in landed { + self.splash(&stack, at, mob, owner); + } + } + /// `AreaEffectCloud.tick` for the lingering potions' clouds: the client + /// tick's particles, then the server tick. + fn tick_potion_clouds(&mut self) { + if self.breath_clouds.iter().all(|c| c.potion.is_none()) { + return; + } + let players: Vec = self + .fight_players() + .into_iter() + .filter(|p| p.alive) + .collect(); + let mut clouds = std::mem::take(&mut self.breath_clouds); + clouds.retain_mut(|c| c.potion.is_none() || self.tick_potion_cloud(c, &players)); + self.breath_clouds.append(&mut clouds); + } + /// `AreaEffectCloud.serverTick` for a lingering potion's cloud: after its + /// wait it shrinks, and every 5 ticks it affects the living entities in + /// its box and radius that were not affected in the last 20 ticks, each + /// taking some of its radius. False once it is gone. + fn tick_potion_cloud(&mut self, c: &mut Breath, players: &[FightPlayer]) -> bool { + c.age += 1; + let Some(potion) = c.potion.as_mut() else { + return true; + }; + if c.age - LINGERING_WAIT >= c.duration { + return false; + } + let waiting = c.age < LINGERING_WAIT; + cloud_client_tick( + &mut self.particles, + c.at, + c.radius, + waiting, + &potion.particle(), + ); + if waiting { + return true; + } + // setRadiusPerTick(-radius / duration). + c.radius += -3.0 / 600.0; + if c.radius < 0.5 { + return false; + } + if c.age % 5 != 0 { + return true; + } + potion.victims.retain(|(_, until)| c.age < *until); + let r = f64::from(c.radius); + let area = Aabb::new( + Vec3::new(c.at[0] - r, c.at[1], c.at[2] - r), + Vec3::new(c.at[0] + r, c.at[1] + 0.5, c.at[2] + r), + ); + let mut inside = Vec::new(); + for p in players.iter().filter(|p| p.bounds.intersects(area)) { + inside.push((Victim::Player(p.who), p.position.x, p.position.z)); + } + let effects = potion.effects; + let tags = &self.res.stations().enchantments.entity_tags; + // Only mobs that at least one of the cloud's effects can affect are + // taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so + // the others neither become victims nor use up its radius. + for m in self.mobs.iter().filter(|m| { + m.alive() + && m.bounds().intersects(area) + && effects + .iter() + .any(|(id, ..)| mob_can_be_affected(tags, &m.kind.id, id)) + }) { + inside.push((Victim::Mob(m.id), m.position.x, m.position.z)); + } + for (victim, x, z) in inside { + let (dx, dz) = (x - c.at[0], z - c.at[2]); + if potion.victims.iter().any(|(v, _)| *v == victim) + || dx * dx + dz * dz > f64::from(c.radius * c.radius) + { + continue; + } + potion.victims.push((victim, c.age + REAPPLICATION_DELAY)); + self.cloud_affect(victim, effects, c.at, &potion.source); + // setRadiusOnUse(-0.5). + c.radius -= 0.5; + if c.radius < 0.5 { + return false; + } + } + true + } + /// What a cloud puts on one entity: instant effects at half strength + /// (`applyInstantaneousEffect` with 0.5), the others for their duration + /// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`). + fn cloud_affect( + &mut self, + victim: Victim, + effects: &'static [(&'static str, i32, i32)], + at: [f64; 3], + source: &Text, + ) { + let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1); + match victim { + Victim::Player(who) => self.as_fight_player(who, |g| { + for (id, duration, amp) in effects { + let Some(e) = brewing::effect(id) else { + continue; + }; + if e.instant { + if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) { + g.apply_indirect_effect_action(a, source); + } + } else { + g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp)); + } + } + }), + Victim::Mob(mob) => { + let Some(i) = self.mobs.iter().position(|m| m.id == mob) else { + return; + }; + // Undead invert healing and harming. + let tags = &self.res.stations().enchantments.entity_tags; + let inverted = tags + .get("minecraft:inverted_healing_and_harm") + .is_some_and(|t| t.contains(&self.mobs[i].kind.id)); + for (id, duration, amp) in effects { + let m = &mut self.mobs[i]; + match *id { + "instant_damage" | "instant_health" => { + let amount = + |base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32; + if (*id == "instant_damage") != inverted { + m.hurt(amount(6), Some(at), &mut self.world.random); + } else { + m.health = (m.health + amount(4)).min(m.kind.max_health); + } + } + _ => { + // `LivingEntity.addEffect` refuses what the mob + // cannot be affected by. + if mob_can_be_affected(tags, &m.kind.id, id) + && let Some(name) = mob_effect_id(id) + { + m.add_effect(name, scaled(*duration), *amp); + } + } + } + } + } } } /// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which @@ -672,12 +893,23 @@ impl Game<'_> { self.submit_item_world(&state, pose, vertex_light(light), out); } } + /// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a + /// potion or a cloud): indirectMagic, not plain magic. + fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) { + let a = match a { + EffectAction::Hurt("minecraft:magic", amount) => { + self.note_direct_entity("minecraft:indirect_magic", source.clone()); + EffectAction::Hurt("minecraft:indirect_magic", amount) + } + a => a, + }; + self.apply_effect_action(a); + } /// A splash potion's effects on the player in place, by their distance. fn splash_on_player( &mut self, effects: &'static [(&'static str, i32, i32)], at: Vec3, - scale: f64, source: &Text, ) { if let Some(p) = self.player.as_ref() { @@ -694,22 +926,10 @@ impl Game<'_> { let instant = brewing::effect(id).is_some_and(|e| e.instant); if instant { if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) { - // HealOrHarmMobEffect.applyInstantaneousEffect with - // a source: indirectMagic, not plain magic. - let a = match a { - EffectAction::Hurt("minecraft:magic", amount) => { - self.note_direct_entity( - "minecraft:indirect_magic", - source.clone(), - ); - EffectAction::Hurt("minecraft:indirect_magic", amount) - } - a => a, - }; - self.apply_effect_action(a); + self.apply_indirect_effect_action(a, source); } } else { - let d = (intensity * f64::from(*duration) * scale + 0.5) as i32; + let d = (intensity * f64::from(*duration) + 0.5) as i32; if d > 20 && let Some(e) = brewing::effect(id) { @@ -720,8 +940,9 @@ impl Game<'_> { } } } - /// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash). - fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option) { + /// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion` + /// for a lingering potion; `mob` is where it hit a mob. + fn splash(&mut self, stack: &ItemStack, at: Vec3, mob: Option, owner: Option) { let block = [at.x, at.y, at.z].map(|v| v.floor() as i32); if &*stack.item == "minecraft:experience_bottle" { // ThrownExperienceBottle.onHit: the water-coloured splash (level @@ -745,23 +966,41 @@ impl Game<'_> { let color = brewing::potion_color(stack.potion()); self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32); self.level_event_at(if instant { 1054 } else { 1053 }, block, 0); - let scale = if lingering { 0.25 } else { 1.0 }; let Some(def) = potion else { return; }; + if lingering { + // The cloud sits at the mob it hit (its feet), else the impact; + // radius 3, wait 10, 600 ticks, shrinking to nothing, and a + // potion with no effects leaves none. + if !def.effects.is_empty() { + let at = mob.unwrap_or(at); + self.breath_clouds.push(Breath { + id: 0, + at: [at.x, at.y, at.z], + radius: 3.0, + age: 0, + duration: 600, + from_dragon: false, + potion: Some(PotionCloud { + effects: def.effects, + color, + victims: Vec::new(), + source: owner.unwrap_or_else(|| { + Text::translate("entity.minecraft.area_effect_cloud", &[]) + }), + }), + }); + } + return; + } // Every player in range (`ThrownSplashPotion.applySplash` over // `LivingEntity`s, the players among them). - // Instant damage is indirectMagic(potion or cloud, owner), which the - // death message names by the owner, else by the potion or cloud. - let source = owner.unwrap_or_else(|| { - let entity = if lingering { - "entity.minecraft.area_effect_cloud" - } else { - "entity.minecraft.splash_potion" - }; - Text::translate(entity, &[]) - }); - self.for_each_player(|g| g.splash_on_player(def.effects, at, scale, &source)); + // Instant damage is indirectMagic(potion, owner), which the death + // message names by the owner, else by the potion. + let source = + owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[])); + self.for_each_player(|g| g.splash_on_player(def.effects, at, &source)); // Mobs take the instant effects (undead invert healing and harming). let undead = &self.res.stations().enchantments.entity_tags; let mut hits = Vec::new(); @@ -784,7 +1023,7 @@ impl Game<'_> { _ => { // `Potion.applySplash`: timed effects last in // proportion to how close the mob was. - let d = (intensity * f64::from(*duration) * scale + 0.5) as i32; + let d = (intensity * f64::from(*duration) + 0.5) as i32; if d > 20 && let Some(name) = mob_effect_id(id) { diff --git a/crates/horizon-client/src/game/remote_mob_use.rs b/crates/horizon-client/src/game/remote_mob_use.rs index 9575cf925..bd38c7301 100644 --- a/crates/horizon-client/src/game/remote_mob_use.rs +++ b/crates/horizon-client/src/game/remote_mob_use.rs @@ -66,8 +66,11 @@ impl Game<'_> { | Species::Salmon | Species::TropicalFish | Species::Pufferfish - | Species::Axolotl - | Species::Tadpole => item == "minecraft:water_bucket", + | Species::Axolotl => item == "minecraft:water_bucket", + // Tadpole.mobInteract: frog food, else the bucket. + Species::Tadpole => { + item == "minecraft:water_bucket" || m.kind.food.iter().any(|f| f.as_str() == item) + } Species::Armadillo => item == "minecraft:brush", // ZombieVillager.mobInteract: CONSUME for a golden apple. Species::ZombieVillager => item == "minecraft:golden_apple", diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 474c85d70..06270e4f0 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -23,6 +23,58 @@ fn default_cloud_particle() -> ParticleOptions { ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4]) } +/// `AreaEffectCloud.clientTick`: while waiting, now and then two particles +/// near the middle; then about one per square block of the disc, drifting +/// (an entity effect stays still). +pub(super) fn cloud_client_tick( + particles: &mut horizon_client::particles::ParticleEngine, + at: [f64; 3], + radius: f32, + waiting: bool, + particle: &ParticleOptions, +) { + if waiting && particles.random().next_boolean() { + return; + } + let (count, spread) = if waiting { + (2, 0.2) + } else { + ( + (std::f32::consts::PI * radius * radius).ceil() as i32, + radius, + ) + }; + for _ in 0..count { + let r = particles.random(); + let angle = r.next_float() * std::f32::consts::TAU; + let distance = r.next_float().sqrt() * spread; + let at = [ + at[0] + f64::from(angle.cos() * distance), + at[1], + at[2] + f64::from(angle.sin() * distance), + ]; + let (options, velocity) = if particle.ty == ParticleType::EntityEffect { + let default = waiting && r.next_boolean(); + let options = if default { + default_cloud_particle() + } else { + particle.clone() + }; + (options, [0.0; 3]) + } else if waiting { + (particle.clone(), [0.0; 3]) + } else { + let v = [ + (0.5 - r.next_double()) * 0.15, + f64::from(0.01f32), + (0.5 - r.next_double()) * 0.15, + ]; + (particle.clone(), v) + }; + particles.add(&options, at, velocity); + } +} + /// The projectile a type id names, as Horizon models it. fn projectile_kind(kind: &str) -> Option { Some(match kind { @@ -446,52 +498,10 @@ impl Game<'_> { } } - /// `AreaEffectCloud.clientTick`: while waiting, now and then two - /// particles near the middle; then about one per square block of the - /// disc, drifting (an entity effect stays still). + /// `AreaEffectCloud.clientTick` for each cloud a server tracks. fn tick_remote_clouds(&mut self) { for c in &self.potions.clouds { - let r = self.particles.random(); - if c.waiting && r.next_boolean() { - continue; - } - let (count, spread) = if c.waiting { - (2, 0.2) - } else { - ( - (std::f32::consts::PI * c.radius * c.radius).ceil() as i32, - c.radius, - ) - }; - for _ in 0..count { - let r = self.particles.random(); - let angle = r.next_float() * std::f32::consts::TAU; - let distance = r.next_float().sqrt() * spread; - let at = [ - c.at[0] + f64::from(angle.cos() * distance), - c.at[1], - c.at[2] + f64::from(angle.sin() * distance), - ]; - let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect { - let default = c.waiting && r.next_boolean(); - let options = if default { - default_cloud_particle() - } else { - c.particle.clone() - }; - (options, [0.0; 3]) - } else if c.waiting { - (c.particle.clone(), [0.0; 3]) - } else { - let v = [ - (0.5 - r.next_double()) * 0.15, - f64::from(0.01f32), - (0.5 - r.next_double()) * 0.15, - ]; - (c.particle.clone(), v) - }; - self.particles.add(&options, at, velocity); - } + cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle); } } diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index 58d25de0e..d0769b894 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -3,7 +3,7 @@ //! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the //! server list's setting, downloads into `downloads/`, and a resource reload //! with the server's packs on top of the player's own. -use super::Game; +use super::{Game, Progress}; use horizon_client::{ Result, confirm_screen::Confirm, @@ -82,6 +82,9 @@ pub(super) struct ServerPackState { pub(super) active: Vec, /// The download batches' notifiers, oldest first. notifiers: Vec, + /// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the + /// frame draws its progress until `tick_server_packs` applies it. + pub(super) reload: Option, } /// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its /// own `SystemToast`. @@ -191,6 +194,21 @@ fn saves() -> PathBuf { std::env::current_dir().unwrap_or_default().join("saves") } +/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`. +const RELOAD_LINES: [&str; 4] = [ + "Preparing resources", + "Loading assets", + "Loading pack tables", + "Applying resources", +]; +/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a +/// worker while `progress` feeds the loading screen, then the apply lands +/// on the render thread. +pub(super) struct PackReload { + pub(super) progress: Progress, + handle: std::thread::JoinHandle>, +} + impl Game<'_> { /// `ConnectScreen`: the server's setting decides what its packs do /// (`configureForServerControl`); single player's own server takes them @@ -314,6 +332,7 @@ impl Game<'_> { if self.rest_loader.is_some() || self.world_loader.is_some() { return; } + self.drive_pack_reload(); let finished = self .server_packs .queue @@ -328,13 +347,8 @@ impl Game<'_> { if !tick.download.is_empty() { self.start_pack_downloads(tick.download); } - if let Some(mut load) = tick.reload { - while let Some(recovery) = { - let ok = self.reload_server_packs(&load); - self.server_packs.packs.reloaded(ok) - } { - load = recovery; - } + if let Some(load) = tick.reload { + self.start_pack_reload(&load); } } for (id, status) in self.server_packs.packs.take_replies() { @@ -379,24 +393,90 @@ impl Game<'_> { } } /// `DownloadedPackSource.startReload`: the packs on top of the player's - /// own, then `Minecraft.reloadResourcePacks`. False when a pack has no - /// readable `pack.mcmeta` or the reload failed. - fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool { - let packs: Option> = load + /// own, staged on a worker (`Minecraft.reloadResourcePacks` under a + /// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no + /// readable `pack.mcmeta` fails the reload at once, as `onFailure`. + fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) { + let server: Option> = load .iter() .map(|(id, path)| packs::server_pack(*id, path)) .collect(); - let Some(packs) = packs else { + let Some(server) = server else { log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all"); - return false; + self.finish_pack_reload(false); + return; }; - self.server_packs.active = packs; - match self.reload_selected_packs() { + self.server_packs.active = server.clone(); + // The overlay swallows input until the apply (`LoadingOverlay`): + // release what is held so nothing keeps acting while it covers the + // frame (`KeyMapping.releaseAll` when a screen opens). + self.keys.clear(); + self.physical_keys.clear(); + self.left = false; + self.right = false; + let vanilla = self.vanilla_root(); + if self.ui.vanilla_root.as_os_str().is_empty() { + self.ui.vanilla_root = vanilla.clone(); + } + let (selected, mods, dev_mods) = ( + self.settings.resource_packs.clone(), + self.settings.mods.enabled.clone(), + self.options.dev_mods.clone(), + ); + let (assets, res, context) = ( + self.pack.assets.clone(), + self.res.clone(), + self.pack.context.clone(), + ); + let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs); + let progress = Progress::with(&RELOAD_LINES); + let worker = progress.clone(); + let handle = std::thread::Builder::new() + .name("server-pack-reload".into()) + .spawn(move || { + super::packs::stage_pack( + &vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, + unicode, japanese, &worker, + ) + .map_err(|e| e.to_string()) + }); + match handle { + Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }), + Err(e) => { + log::error!("Horizon: could not start the pack reload worker: {e}"); + self.finish_pack_reload(false); + } + } + } + /// `PackReloadConfig.Callbacks`: applies the staged reload once its + /// worker is done; on failure the packs active before reload again. + fn drive_pack_reload(&mut self) { + let Some(reload) = &self.server_packs.reload else { + return; + }; + if !reload.handle.is_finished() { + return; + } + let reload = self.server_packs.reload.take().unwrap(); + let staged = reload + .handle + .join() + .unwrap_or_else(|_| Err("the pack reload worker panicked".into())); + let ok = match staged + .and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())) + { Ok(()) => true, Err(e) => { log::warn!("Horizon: server resource pack reload failed: {e}"); false } + }; + self.finish_pack_reload(ok); + } + /// `ServerPacks::reloaded` and the recovery reload it can ask for. + fn finish_pack_reload(&mut self, ok: bool) { + if let Some(recovery) = self.server_packs.packs.reloaded(ok) { + self.start_pack_reload(&recovery); } } diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index 2d010f1f5..3d946ce7b 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -5,7 +5,8 @@ use super::{Game, lan::LanHost}; use horizon_client::{ containers::BlockEntity, hud::{HudBatch, rgba}, - signs::wood_type, + items::ItemStack, + signs::{DYE_TEXT, wood_type}, ui::EditBox, }; use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S}; @@ -31,6 +32,157 @@ fn sign_box() -> EditBox { } impl Game<'_> { + /// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in + /// front of the sign's text (within 90 degrees of its facing). + fn facing_sign_front(&self, pos: (i32, i32, i32)) -> bool { + let Some(player) = self.player.as_ref() else { + return true; + }; + let state = self.world.block(pos.0, pos.1, pos.2); + let Some(state) = self.pack.assets.registry.state(state) else { + return true; + }; + let prop = |key: &str| state.properties.get(key).map(String::as_str); + // getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or + // ceiling sign's rotation segment. + let y_rot = if state.name.contains("_wall_") { + match prop("facing") { + Some("west") => 90.0, + Some("north") => 180.0, + Some("east") => 270.0, + _ => 0.0, + } + } else { + prop("rotation") + .and_then(|r| r.parse::().ok()) + .unwrap_or(0.0) + * 22.5 + }; + // getSignHitboxCenterPosition: only a wall sign's board is off-centre, + // against the wall it hangs on. + let centre = if state.name.ends_with("_wall_sign") { + match y_rot as i32 { + 90 => (0.9375, 0.5), + 180 => (0.5, 0.9375), + 270 => (0.0625, 0.5), + _ => (0.5, 0.0625), + } + } else { + (0.5, 0.5) + }; + let xd = player.position.x - (f64::from(pos.0) + centre.0); + let zd = player.position.z - (f64::from(pos.2) + centre.1); + let player_y_rot = (zd.atan2(xd).to_degrees() - 90.0) as f32; + // Mth.degreesDifferenceAbs. + let diff = (player_y_rot - y_rot).rem_euclid(360.0); + let diff = if diff >= 180.0 { 360.0 - diff } else { diff }; + diff <= 90.0 + } + /// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed + /// sign plays the failed-interaction sound; otherwise a player who may + /// build edits the side they face. + pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) { + let key = (pos[0], pos[1], pos[2]); + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else { + return; + }; + if sign.waxed { + // `getSignInteractionFailedSoundEvent`: a hanging sign has its own. + let sound = if sign.hanging { + "minecraft:block.hanging_sign.waxed_interact_fail" + } else { + "minecraft:block.sign.waxed_interact_fail" + }; + self.audio + .play(sound, Some(pos.map(|v| f64::from(v) + 0.5)), 1.0, 1.0); + } else if self.may_build() { + let front = self.facing_sign_front(key); + self.open_sign_editor(key, front); + } + } + /// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs, + /// honeycomb): the item changes the side the player faces and is used up. + /// Whether it did; a sign it does not apply to goes on to the empty hand. + pub(super) fn use_sign_applicator(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool { + let Some(stack) = held else { + return false; + }; + let key = (pos[0], pos[1], pos[2]); + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else { + return false; + }; + if sign.waxed || !self.may_build() { + return false; + } + let item = &*stack.item; + let dye = if item.ends_with("_dye") { + self.res + .items() + .get(&stack.item) + .and_then(|i| i.dye.clone()) + } else { + None + }; + let colour = dye.and_then(|d| DYE_TEXT.iter().position(|(n, _)| *n == d)); + if !matches!( + item, + "minecraft:honeycomb" | "minecraft:glow_ink_sac" | "minecraft:ink_sac" + ) && colour.is_none() + { + return false; + } + let front = self.facing_sign_front(key); + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else { + return false; + }; + let centre = pos.map(|v| f64::from(v) + 0.5); + let sound = if item == "minecraft:honeycomb" { + // HoneycombItem.tryApplyToSign: waxes any sign, empty or not. + sign.waxed = true; + "minecraft:item.honeycomb.wax_on" + } else { + let side = if front { + &mut sign.front + } else { + &mut sign.back + }; + // SignApplicator.canApplyToSign: the side must have text. + if side.is_empty() { + return false; + } + match colour { + Some(c) if side.color != c as u8 => { + side.color = c as u8; + "minecraft:item.dye.use" + } + None if item == "minecraft:glow_ink_sac" && !side.glowing => { + side.glowing = true; + "minecraft:item.glow_ink_sac.use" + } + None if item == "minecraft:ink_sac" && side.glowing => { + side.glowing = false; + "minecraft:item.ink_sac.use" + } + _ => return false, + } + }; + if item == "minecraft:honeycomb" { + self.level_event_at(3003, pos, 0); + } + self.audio.play(sound, Some(centre), 1.0, 1.0); + self.mark_block_entity_updated(key); + let state = self.world.block(pos[0], pos[1], pos[2]); + self.post_block_game_event( + "minecraft:block_change", + pos, + self.sculk_actor(), + Some(state), + ); + self.stats.add("used", &stack.item, 1); + self.consume_held(); + self.trigger_block("minecraft:item_used_on_block", pos, stack); + true + } /// `SignBlock.openTextEdit` for the front side after placement. pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) { self.sign_edit = Some(SignEditor { diff --git a/crates/horizon-client/src/game/spawner.rs b/crates/horizon-client/src/game/spawner.rs index 538cb39e7..5dd907afb 100644 --- a/crates/horizon-client/src/game/spawner.rs +++ b/crates/horizon-client/src/game/spawner.rs @@ -253,7 +253,7 @@ impl Game<'_> { self.world.modified.insert((key.0 >> 4, key.2 >> 4)); } /// `LivingEntity.makePoofParticles` (entity event 20) for a mob. - fn mob_poof(&mut self, id: u32) { + pub(super) fn mob_poof(&mut self, id: u32) { let Some(m) = self.mobs.iter().find(|m| m.id == id) else { return; }; diff --git a/crates/horizon-client/src/game/stats.rs b/crates/horizon-client/src/game/stats.rs index 7f64c6bb2..68a574eb1 100644 --- a/crates/horizon-client/src/game/stats.rs +++ b/crates/horizon-client/src/game/stats.rs @@ -256,9 +256,8 @@ impl Game<'_> { .fluid == horizon_client::blocks::Fluid::Water; let on_ground = p.ground.on_ground; - let (sprinting, flying, gliding, swimming) = ( + let (sprinting, gliding, swimming) = ( p.sprinting, - p.flying, p.fall_flying, p.pose == horizon_client::pose::Pose::Swimming, ); @@ -272,15 +271,27 @@ impl Game<'_> { }); let s = &mut self.stats; match riding { - Some(Ok(horizon_client::mobs::Species::Pig)) => s.custom("pig_one_cm", total), - Some(Ok(horizon_client::mobs::Species::Strider)) => s.custom("strider_one_cm", total), - Some(Ok(sp)) if horizon_client::mobs::equine::is_equine(sp) => { - s.custom("horse_one_cm", horizontal) + Some(Ok(sp)) => { + use horizon_client::mobs::Species; + // `checkRidingStatistics`: only these mounts count, the horse + // family by the full 3D distance; any other mount nothing. + match sp { + Species::Pig => s.custom("pig_one_cm", total), + Species::Strider => s.custom("strider_one_cm", total), + Species::HappyGhast => s.custom("happy_ghast_one_cm", total), + Species::Nautilus | Species::ZombieNautilus => { + s.custom("nautilus_one_cm", total) + } + sp if horizon_client::mobs::equine::is_equine(sp) => { + s.custom("horse_one_cm", total) + } + _ => {} + } } - Some(_) => { + Some(Err(())) => { if self.boats.iter().any(|b| Some(b.id) == self.riding) { s.custom("boat_one_cm", total) - } else { + } else if self.carts.iter().any(|c| Some(c.id) == self.riding) { s.custom("minecart_one_cm", total) } } @@ -300,8 +311,9 @@ impl Game<'_> { } } None if gliding => s.custom("aviate_one_cm", total), - None if flying => s.custom("fly_one_cm", horizontal), - None if d.y < 0.0 => s.custom("fall_one_cm", (-d.y * 100.0).round() as i32), + // Airborne and not gliding: more than 25 cm sideways per move + // (creative flight, or a long jump), not only flying. + None if horizontal > 25 => s.custom("fly_one_cm", horizontal), None => {} } } diff --git a/crates/horizon-client/src/game/trading.rs b/crates/horizon-client/src/game/trading.rs index 6986d8baa..0f8723737 100644 --- a/crates/horizon-client/src/game/trading.rs +++ b/crates/horizon-client/src/game/trading.rs @@ -245,7 +245,7 @@ impl Game<'_> { self.handle_mob_events(events)?; return Ok(true); } - self.stats.custom("talk_to_villager", 1); + self.stats.custom("talked_to_villager", 1); self.ensure_offers(index); let m = &self.mobs[index]; let trade = m.extra.trade.as_deref().cloned().unwrap_or_default(); @@ -398,18 +398,21 @@ impl Game<'_> { .as_ref() .and_then(|s| s.merchant.as_ref()) .and_then(|m| m.mob); - let session = self.merchant_apply(session, mob); + let (session, traded) = self.merchant_apply(session, mob); + self.merchant_traded(mob, &traded); if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) { m.session = session; } } - /// What a menu click's session did, applied to the world and its mob. + /// What a menu click's session did, applied to the world and its mob; + /// with the result of each trade made, for `merchant_traded`. pub(super) fn merchant_apply( &mut self, session: Box, mob: Option, - ) -> Box { + ) -> (Box, Vec) { let mut session = session; + let mut traded = Vec::new(); let events = std::mem::take(&mut session.events); let (villager, origin) = (session.villager, session.origin); if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) { @@ -438,11 +441,26 @@ impl Game<'_> { }; self.audio.play(sound, Some(origin), 1.0, pitch); } - merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1), + merchant::Event::Traded(index) => { + traded.extend(session.offers.get(index).map(|o| o.result.clone())); + } merchant::Event::TradeSound => {} } } - session + (session, traded) + } + /// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade` + /// for each trade made: the statistic and the `villager_trade` trigger. + pub(super) fn merchant_traded(&mut self, mob: Option, results: &[ItemStack]) { + for result in results { + self.stats.custom("traded_with_villager", 1); + let villager = mob + .and_then(|id| self.mobs.iter().find(|m| m.id == id)) + .map(|m| self.mob_entity(m)); + if let Some(villager) = villager { + self.trigger_trade(&villager, result); + } + } } /// `Villager.customServerAiStep`: the TRADE reputation event of the last /// trade, a tick later; the prices follow the new reputation. diff --git a/crates/horizon-client/src/game/vault.rs b/crates/horizon-client/src/game/vault.rs index f3edfddb3..18430daf6 100644 --- a/crates/horizon-client/src/game/vault.rs +++ b/crates/horizon-client/src/game/vault.rs @@ -169,6 +169,8 @@ impl Game<'_> { self.consume_held(); } } + // ServerPlayerGameMode.useItemOn: the action was consumed. + self.trigger_block("minecraft:item_used_on_block", pos, &stack); Ok(true) } fn vault_events(&mut self, key: Pos, events: Vec) -> Result<()> { diff --git a/crates/horizon-client/src/health.rs b/crates/horizon-client/src/health.rs index 4514c2bcf..31e4e8104 100644 --- a/crates/horizon-client/src/health.rs +++ b/crates/horizon-client/src/health.rs @@ -29,12 +29,22 @@ pub(crate) type State = HurtState; pub struct HealthRules { pub difficulty: Difficulty, pub natural_regeneration: bool, + /// The `drowning_damage`, `fall_damage`, `fire_damage` and + /// `freeze_damage` rules `Player.isInvulnerableTo` reads. + pub drowning_damage: bool, + pub fall_damage: bool, + pub fire_damage: bool, + pub freeze_damage: bool, } impl Default for HealthRules { fn default() -> Self { Self { difficulty: Difficulty::Normal, natural_regeneration: true, + drowning_damage: true, + fall_damage: true, + fire_damage: true, + freeze_damage: true, } } } @@ -633,8 +643,13 @@ impl ImmunityWorld for Environment<'_> { fn enchantment_immunity(&mut self) -> io::Result { Ok(false) } - fn damage_rule(&mut self, _: DamageRule) -> io::Result { - Ok(true) + fn damage_rule(&mut self, rule: DamageRule) -> io::Result { + Ok(match rule { + DamageRule::Drowning => self.rules.drowning_damage, + DamageRule::Fall => self.rules.fall_damage, + DamageRule::Fire => self.rules.fire_damage, + DamageRule::Freeze => self.rules.freeze_damage, + }) } fn changing_dimension(&mut self) -> io::Result { Ok(false) diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 50ce01f9f..6179162ef 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1320,6 +1320,8 @@ pub struct Seen { pub active_raid: bool, /// A copper golem's `openedChestPos` (`ContainerUser.hasContainerOpen`). pub opened_chest: Option<[i32; 3]>, + /// `isPassenger`. + pub passenger: bool, /// A villager's reputation of the player (`getPlayerReputation`). pub player_rep: i32, /// `isPushable` and pushed by others (`Entity.push(Entity)` moves it). @@ -1411,6 +1413,7 @@ impl Mob { hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)), active_raid: raider::has_active_raid(m), opened_chest: m.extra.opened_chest, + passenger: m.extra.mount.is_some(), player_rep: match (villager, player) { (true, Some(uuid)) => villager::ext(m).map_or(0, |e| e.gossips.reputation(uuid)), _ => 0, @@ -1590,6 +1593,8 @@ pub struct PlayerView { /// A LAN guest: its own client moves it, and touching mobs push it there /// (`EntitySelector.pushableBy` on the client). pub remote: bool, + /// `isPassenger`. + pub passenger: bool, } impl PlayerView { /// `LivingEntity.hasEffect`, by id without namespace. @@ -1920,7 +1925,7 @@ impl Mob { let dead = !self.alive(); let sound = ai::voice_sounds(self)[if dead { 2 } else { 1 }]; if !dead { - self.ambient_timer = -self.kind.ambient_interval; + self.ambient_timer = -self.ambient_interval(); } if !sound.is_empty() { let pitch = self.voice_pitch(); @@ -2030,6 +2035,16 @@ impl Mob { let at = [self.position.x, self.eye_y(), self.position.z]; events.push(MobEvent::Sound(event, at, 1.0, 1.0)); } + /// `Mob.getAmbientSoundInterval`; `HappyGhast` waits six times as long + /// while it carries a passenger. + pub fn ambient_interval(&self) -> i32 { + let ridden = self.rider.is_some() || self.extra.rider.is_some(); + if self.species() == Species::HappyGhast && ridden { + self.kind.ambient_interval * 6 + } else { + self.kind.ambient_interval + } + } /// LivingEntity.getVoicePitch. pub fn voice_pitch(&mut self) -> f32 { // HappyGhast.getVoicePitch: always 1.0, babies and all. diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 9adf93a8f..3d4b84f3e 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -185,6 +185,18 @@ pub fn register(m: &mut Mob) { if m.species() == Species::Rabbit { super::rabbit::register(m); } + if m.species() == Species::Frog { + // `Frog()`: SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true). + // The speed attribute is 1.0, so the controller's scaling is + // what keeps a frog at a walking pace on land. + m.ai.mov.kind = super::control::MoveKind::SmoothSwimming { + max_turn_x: 85.0, + max_turn_y: 10.0, + in_water: 0.02, + outside: 0.1, + gravity: true, + }; + } if m.species() == Species::Turtle { super::turtle::register(m); } @@ -321,6 +333,20 @@ fn generic(m: &mut Mob) { m.ai.targets.add(1, HurtByTarget::new()); m.ai.targets.add(2, NearestTarget::player(true)); } else { + if m.kind.species == Species::Goat { + // GoatAi's idle and core behaviours (the ram and long jump + // activities are not here): panic 2.0, AnimalMakeLove, tempting + // with goat_food and baby follow at 1.25, then stroll. + g.add(0, Float); + g.add(1, Panic::new(2.0)); + g.add(2, Breed::new(1.0)); + g.add(3, Tempt::new(1.25, m.kind.food.clone(), false)); + g.add(4, FollowParent::new(1.25)); + g.add(5, Stroll::avoiding_water(1.0)); + g.add(6, LookAtPlayer::new(6.0)); + g.add(7, RandomLookAround::default()); + return; + } g.add(0, Float); let rabbit = m.kind.species == Species::Rabbit; g.add(1, Panic::new(if rabbit { 2.2 } else { 1.25 })); @@ -609,7 +635,7 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { let t = m.ambient_timer; m.ambient_timer += 1; if roll < t { - m.ambient_timer = -m.kind.ambient_interval; + m.ambient_timer = -m.ambient_interval(); play_ambient(m, w, events); } } diff --git a/crates/horizon-client/src/mobs/bee.rs b/crates/horizon-client/src/mobs/bee.rs index 27d8e7c84..cecaff589 100644 --- a/crates/horizon-client/src/mobs/bee.rs +++ b/crates/horizon-client/src/mobs/bee.rs @@ -323,12 +323,19 @@ pub fn sting(m: &mut Mob, who: Who, cx: &mut Cx) { match who { Who::Player => { cx.events.push(MobEvent::AttackPlayer(amount, from)); - let seconds = if cx.w.difficulty.hard { 18 } else { 10 }; - cx.events.push(MobEvent::EffectPlayer { - effect: "minecraft:poison", - ticks: seconds * 20, - amplifier: 0, - }); + // Poison only on normal (10 s) and hard (18 s). + let seconds = match cx.w.difficulty.id { + 2 => 10, + 3 => 18, + _ => 0, + }; + if seconds > 0 { + cx.events.push(MobEvent::EffectPlayer { + effect: "minecraft:poison", + ticks: seconds * 20, + amplifier: 0, + }); + } } Who::Mob(id) => cx.events.push(MobEvent::AttackMob { target: id, @@ -421,10 +428,10 @@ pub fn drop_off_nectar(m: &mut Mob) { set_has_nectar(m, false); st(m).crops_grown = 0; } -/// `Bee.setStayOutOfHiveCountdown`, which in 26.3 only resets the time -/// without nectar. +/// `Bee.setStayOutOfHiveCountdown`: the bee will not enter a hive for 400 +/// ticks (the hive passes that when a sedated hive lets its bees out). pub fn set_stay_out_of_hive_countdown(m: &mut Mob) { - st(m).ticks_without_nectar = 0; + st(m).stay_out_of_hive = 400; } fn drop_hive(m: &mut Mob) { let b = st(m); diff --git a/crates/horizon-client/src/mobs/drops.rs b/crates/horizon-client/src/mobs/drops.rs index 064cf0583..3168d8f1e 100644 --- a/crates/horizon-client/src/mobs/drops.rs +++ b/crates/horizon-client/src/mobs/drops.rs @@ -403,6 +403,15 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec (Vec 0 { + out.push(Drop::Item(loot_stack(&item, carried))); + } + if let Some(stack) = slot_item(m, "mainhand") + && !d.loot.prevents_equipment_drop(&stack) + { + out.push(Drop::Item(stack)); + clear_slot(m, "mainhand"); + } + } // Piglin.dropCustomDeathLoot: everything in its inventory. if s == Species::Piglin { for stack in m.extra.inventory.drain(..) { diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 389457a11..6c6923c32 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -660,7 +660,11 @@ impl Mob { return; } let power = self.fall_distance + 1.0e-6 - self.kind.safe_fall_distance; - let damage = (power * self.kind.fall_damage_multiplier).floor(); + let mut damage = (power * self.kind.fall_damage_multiplier).floor(); + // Frog.calculateFallDamage: five points less than the base damage. + if self.species() == super::Species::Frog { + damage -= 5.0; + } if damage > 0.0 { let at = [self.position.x, self.position.y, self.position.z]; let sound = if damage > 4.0 { diff --git a/crates/horizon-client/src/mobs/monster_brain.rs b/crates/horizon-client/src/mobs/monster_brain.rs index 9456c00a1..827360406 100644 --- a/crates/horizon-client/src/mobs/monster_brain.rs +++ b/crates/horizon-client/src/mobs/monster_brain.rs @@ -1137,6 +1137,7 @@ struct Quarry { position: Vec3, head_yaw: f32, height: f64, + passenger: bool, } fn quarry(w: &MobWorld, who: Who) -> Option { let t = w.target_info(who)?; @@ -1148,6 +1149,7 @@ fn quarry(w: &MobWorld, who: Who) -> Option { position: t.position, head_yaw, height: t.bounds.max_position().y - t.bounds.min_position().y, + passenger: t.passenger, }) } /// `BreezeUtil.randomPointBehindTarget`. @@ -1233,7 +1235,8 @@ fn shoot_tick(m: &mut Mob, cx: &mut Bx) { let firing = super::breeze::firing_y(m); let (xd, yd, zd) = ( q.position.x - m.position.x, - q.position.y + q.height * 0.3 - firing, + // target.getY(target.isPassenger() ? 0.8 : 0.3). + q.position.y + q.height * if q.passenger { 0.8 } else { 0.3 } - firing, q.position.z - m.position.z, ); let start = Vec3::new(m.position.x, firing, m.position.z); @@ -1242,14 +1245,19 @@ fn shoot_tick(m: &mut Mob, cx: &mut Bx) { start, Some(Who::Mob(m.id)), ); - // The new entity's own random spreads the shot (5 - difficulty * 4 at - // normal): not the breeze's. + // The new entity's own random spreads the shot (5 - difficulty id * 4): + // not the breeze's. let mut own = LegacyRandom::new( cx.now .wrapping_mul(0x5DEE_CE66) .wrapping_add(i64::from(m.id)), ); - p.shoot(&mut own, [xd, yd, zd], 0.7, -3.0); + p.shoot( + &mut own, + [xd, yd, zd], + 0.7, + 5.0 - f32::from(cx.w.difficulty.id) * 4.0, + ); cx.events.push(MobEvent::Projectile(p)); breeze_sound(m, cx, "entity.breeze.shoot", 1.5); } diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index 58118d2c2..3cce491ef 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -39,6 +39,8 @@ pub struct LocalDifficulty { pub normal: bool, /// The world difficulty is hard. pub hard: bool, + /// `Difficulty.getId`: 0 peaceful to 3 hard. + pub id: u8, } /// `DimensionType.MOON_BRIGHTNESS_PER_PHASE`. pub const MOON_BRIGHTNESS: [f32; 8] = [1.0, 0.75, 0.5, 0.25, 0.0, 0.25, 0.5, 0.75]; @@ -53,6 +55,7 @@ impl LocalDifficulty { effective: 0.0, normal, hard, + id: base, }; } let global = ((total as f32 - 72000.0) / 1_440_000.0).clamp(0.0, 1.0) * 0.25; @@ -66,6 +69,7 @@ impl LocalDifficulty { effective: f32::from(base) * (0.75 + global + local), normal, hard, + id: base, } } /// `getSpecialMultiplier`. diff --git a/crates/horizon-client/src/mobs/species.rs b/crates/horizon-client/src/mobs/species.rs index 92d1f21cd..64106574b 100644 --- a/crates/horizon-client/src/mobs/species.rs +++ b/crates/horizon-client/src/mobs/species.rs @@ -1447,7 +1447,6 @@ impl Def { | S::IronGolem | S::Cat | S::Ocelot - | S::Frog | S::Slime | S::MagmaCube | S::Wither @@ -1581,7 +1580,7 @@ impl Def { S::Bee => "bee_food", S::Turtle => "turtle_food", S::Armadillo => "armadillo_food", - S::Frog => "frog_food", + S::Frog | S::Tadpole => "frog_food", S::Axolotl => "axolotl_food", S::Hoglin => "hoglin_food", S::Strider => "strider_food", @@ -1662,6 +1661,12 @@ impl Def { | S::Camel | S::CamelHusk => 400, S::Ocelot => 900, + // Guardian.getAmbientSoundInterval (the elder too). + S::Guardian | S::ElderGuardian => 160, + // Hoglin extends Animal; Shulker extends AbstractGolem. + S::Hoglin | S::Shulker => 120, + // Allay is a PathfinderMob: the Mob default. + S::Allay => 80, S::Squid | S::GlowSquid | S::Cod diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index dc201a3f3..e1d7b7f8d 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -25,6 +25,8 @@ pub struct TargetInfo { pub can_be_seen_as_enemy: bool, pub is_player: bool, pub creative: bool, + /// `isPassenger`. + pub passenger: bool, } impl MobWorld<'_> { /// The biome's base temperature at a position. @@ -249,6 +251,7 @@ impl MobWorld<'_> { can_be_seen_as_enemy: true, is_player: true, creative: p.creative, + passenger: p.passenger, }), Who::Mob(id) => self.seen(id).map(|s| TargetInfo { position: s.position, @@ -258,6 +261,7 @@ impl MobWorld<'_> { can_be_seen_as_enemy: !s.playing_dead, is_player: false, creative: false, + passenger: s.passenger, }), } } diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index 5efc83621..2e7930b4e 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -546,6 +546,17 @@ fn next_double_between(random: &mut impl RandomSource, min: f64, max: f64) -> f6 pub fn on_block_faces( random: &mut impl RandomSource, pos: [i32; 3], + emit: impl FnMut([f64; 3], [f64; 3]), +) { + on_block_faces_between(random, pos, 3, 5, emit); +} +/// `ParticleUtils.spawnParticlesOnBlockFaces` with `UniformInt.of(min, max)` +/// particles per face (3009 sniffer egg cracks use 3 to 6). +pub fn on_block_faces_between( + random: &mut impl RandomSource, + pos: [i32; 3], + min: i32, + max: i32, mut emit: impl FnMut([f64; 3], [f64; 3]), ) { const FACES: [[i32; 3]; 6] = [ @@ -557,7 +568,7 @@ pub fn on_block_faces( [1, 0, 0], ]; for face in FACES { - let count = random.next_int_bound(5 - 3 + 1).unwrap_or(0) + 3; + let count = random.next_int_bound(max - min + 1).unwrap_or(0) + min; for _ in 0..count { // getRandomSpeedRanges, then spawnParticleOnFace with step factor 0.55. let speed: [f64; 3] = std::array::from_fn(|_| next_double_between(random, -0.5, 0.5)); diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index aae9b296b..b3e9938c9 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -109,6 +109,9 @@ pub struct Projectile { pub arrow: Option>, /// `AbstractHurtingProjectile.accelerationPower`. pub acceleration: f64, + /// `Entity.needsSync`: a server tells its clients about the change this + /// tick instead of at the next update interval. + pub needs_sync: bool, } /// What a projectile struck this tick. #[derive(Clone, Copy, Debug, PartialEq)] @@ -173,6 +176,7 @@ impl Projectile { left_owner: false, bonus: Default::default(), acceleration: 0.1, + needs_sync: false, arrow: kind_is_arrow.then(|| { // Entity.random: seeded unpredictably, the position stands in. Box::new(crate::arrows::ArrowState::new( diff --git a/crates/horizon-threads/src/lib.rs b/crates/horizon-threads/src/lib.rs index 85957b7e1..01e815e8b 100644 --- a/crates/horizon-threads/src/lib.rs +++ b/crates/horizon-threads/src/lib.rs @@ -163,6 +163,38 @@ fn idle() -> bool { false } +/// How many stop requests (Ctrl-C, SIGTERM, SIGHUP) arrived since `catch_stop_signals`. +pub fn stop_requests() -> u32 { + STOP_REQUESTS.load(std::sync::atomic::Ordering::SeqCst) +} + +static STOP_REQUESTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Count stop requests from outside instead of dying on them: vanilla's "Client Shutdown Thread" +/// (`Main.java`) halts the integrated server when the JVM shuts down, and the JVM does that on +/// these signals, so a killed game still saves. The caller's event loop turns the first request +/// into a normal quit; a second one (the save is stuck) ends the process at once. +#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))] +pub fn catch_stop_signals() { + extern "C" fn handle(_: libc::c_int) { + if STOP_REQUESTS.fetch_add(1, Ordering::SeqCst) >= 1 { + // SAFETY: _exit is async-signal-safe; 130 is the shell's code for a Ctrl-C death. + unsafe { libc::_exit(130) } + } + } + let handler = handle as extern "C" fn(libc::c_int); + for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] { + // SAFETY: the handler only touches an atomic and calls _exit, both async-signal-safe. + unsafe { + libc::signal(signal, handler as libc::sighandler_t); + } + } +} + +/// Windows and the other systems keep their default: a closed window is a `CloseRequested`. +#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))] +pub fn catch_stop_signals() {} + /// Give back to the OS the memory the allocator holds free, after something /// large was dropped: glibc keeps freed pages in the arena of the thread that /// allocated them, and macOS in its zones until memory runs short. Nothing diff --git a/docs/resource-packs.md b/docs/resource-packs.md index 783c381dd..69eee3393 100644 --- a/docs/resource-packs.md +++ b/docs/resource-packs.md @@ -33,7 +33,7 @@ harness named checks that loader against vanilla). | Overlays per format | `pack.mcmeta` `overlays` | works | `test_resource_pack_parity` (an overlay for 26.3 applied, an older one not) | | Filters | `pack.mcmeta` `filter` | works | `test_resource_pack_parity` (`packs/pattern.rs` regex subset) | | Folder and zip packs, order, built-in packs | `resourcepacks/` | works | `test_resource_pack_parity`, `test_pack_selection_parity` | -| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works, no download toasts | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server | +| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), download toasts, a progress screen while the packs reload, applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server | | Other namespaces | `assets//` | works | `test_resource_pack_parity` | | Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` | | Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` | @@ -84,7 +84,6 @@ shaders, the missing pieces are missing vanilla features too, and each vanilla p | Gap | Size | Where | | --- | --- | --- | | Animation `interpolate` | small | fix PR (`gfx.rs` `animate_sprites`) | -| Server pack download toasts (`download.pack.*`) | small | `game/server_packs.rs` | | GUI scaling `width`/`height`, `stretch_inner`, `tile`, `stretch` | small | next fix PR (`ui.rs`) | | Villager hats | small | [#334](https://github.com/ludovic111/horizon/issues/334) | | `blur`/`clamp` from packs | small | [#335](https://github.com/ludovic111/horizon/issues/335) |