Merge main into fix/bundle-dump-on-use

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-27 19:11:21 +01:00
75 changed files with 2081 additions and 520 deletions
+8
View File
@@ -95,6 +95,14 @@ jobs:
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
- name: Old nightly/build-* branches deleted (scheduled runs only)
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
if: github.event_name == 'schedule'
env:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
+3 -2
View File
@@ -35,7 +35,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
@@ -120,7 +121,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
Generated
+12
View File
@@ -1385,6 +1385,7 @@ dependencies = [
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1469,6 +1470,17 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-items"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"serde",
"serde_json",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
+1
View File
@@ -9,6 +9,7 @@ members = [
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-server",
"crates/horizon-bots",
@@ -1,6 +1,7 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
@@ -299,6 +300,15 @@ pub fn to_text_with(
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -14,6 +14,7 @@ pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod nbt_value;
pub mod particle_type;
pub mod pattern;
pub mod potions;
@@ -0,0 +1,24 @@
//! Reading and writing single NBT values the way vanilla's codecs accept them.
use horizon_world::nbt::Tag;
pub fn string(s: &str) -> Tag {
Tag::String(s.into())
}
pub fn text(tag: Option<&Tag>) -> Option<String> {
match tag {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
}
}
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
pub fn number(tag: Option<&Tag>) -> Option<i64> {
Some(match tag? {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
Tag::Float(b) => f32::from_bits(*b) as i64,
Tag::Double(b) => f64::from_bits(*b) as i64,
_ => return None,
})
}
+2
View File
@@ -11,6 +11,7 @@ description = "Horizon native development client"
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -105,5 +106,6 @@ path = "examples/probe/main.rs"
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -2,7 +2,7 @@
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
#[allow(dead_code)]
#[path = "../src/blocks/decode.rs"]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
use std::{hint::black_box, path::Path, time::Instant};
@@ -1077,6 +1077,8 @@ pub fn main() -> horizon_client::Result<()> {
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
sleeping: false,
sleep_timer: 0,
passenger: false,
}
});
+78
View File
@@ -234,3 +234,81 @@ impl RenderAssets {
})
}
}
#[cfg(test)]
mod tests {
use crate::items::ConsumeEffect;
#[test]
fn always_edible_and_consume_effects_loaded() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
assert!(apple.food.is_some());
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
let enchanted = items
.get("minecraft:enchanted_golden_apple")
.expect("enchanted golden apple");
assert!(enchanted.can_always_eat);
assert!(enchanted.food.is_some());
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
assert!(!beef.can_always_eat);
assert!(beef.food.is_some());
// Consumables without food, their style and their remainders.
use horizon_world::consumable::UseAnimation;
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
let drink = milk.consumable.as_ref().unwrap();
assert!(milk.food.is_none());
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
assert!(!drink.particles);
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
let sip = honey.consumable.as_ref().unwrap();
assert_eq!(sip.ticks(), 40);
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
assert_eq!(
sip.effects,
[ConsumeEffect::RemoveEffects(vec![
"minecraft:poison".into()
])]
);
assert_eq!(
honey.use_remainder.as_deref(),
Some("minecraft:glass_bottle")
);
let kelp = items.get("minecraft:dried_kelp").unwrap();
let bite = kelp.consumable.as_ref().unwrap();
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
assert!(bite.particles && kelp.use_remainder.is_none());
let chorus = items.get("minecraft:chorus_fruit").unwrap();
assert_eq!(
chorus.consumable.as_ref().unwrap().effects,
[ConsumeEffect::TeleportRandomly(
horizon_world::teleport_consume::TeleportConsume {
diameter: 16.0,
directional_particles: true,
}
)]
);
let chicken = items.get("minecraft:chicken").unwrap();
assert!(matches!(
chicken.consumable.as_ref().unwrap().effects[..],
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
}
+1 -53
View File
@@ -3,24 +3,10 @@
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
//! six-layer limit and the result stack).
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BannerLayer {
pub pattern: Arc<str>,
pub color: Arc<str>,
}
impl BannerLayer {
pub fn new(pattern: &str, color: &str) -> Self {
Self {
pattern: Arc::from(pattern),
color: Arc::from(color),
}
}
}
pub use horizon_items::banner_layer::{BannerLayer, layers_from_nbt, layers_to_nbt};
/// Layers a loom may stack on a banner (`LoomMenu`'s `hasMaxPatterns`).
pub const MAX_LAYERS: usize = 6;
@@ -224,44 +210,6 @@ pub fn has_max_layers(banner: &ItemStack) -> bool {
layers(banner).len() >= MAX_LAYERS
}
/// The `banner_patterns` NBT list of layers.
pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag {
Tag::List(
layers
.iter()
.map(|l| {
Tag::compound([
("pattern", Tag::String((&*l.pattern).into())),
("color", Tag::String((&*l.color).into())),
])
})
.collect(),
)
}
pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
let Some(Tag::List(list)) = tag else {
return Vec::new();
};
list.iter()
.filter_map(|e| {
let text = |k: &str| match e.get(k) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
let pattern = text("pattern")?;
let color = text("color")?;
Some(BannerLayer::new(
&if pattern.contains(':') {
pattern
} else {
format!("minecraft:{pattern}")
},
&color,
))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+1 -9
View File
@@ -13,7 +13,6 @@ use crate::{
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
uuid_from_tag,
},
text::Text,
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::{Compound, Tag};
@@ -874,14 +873,7 @@ fn custom_name(tag: &Tag) -> Option<String> {
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
pub use horizon_client_core::component::name_text;
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
let Some(Tag::List(attrs)) = tag.get("attributes") else {
+1 -12
View File
@@ -443,18 +443,7 @@ impl UseResult {
matches!(self, Self::Success { swing: true }) && before == after
}
}
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
use horizon_world::consumable::UseAnimation;
match v
.get("animation")
.and_then(serde_json::Value::as_str)
.unwrap_or("eat")
{
"eat" => UseAnimation::Eat,
"drink" => UseAnimation::Drink,
_ => UseAnimation::None,
}
}
pub use horizon_items::use_animation::consumable_animation;
/// What the click handling reads and does in the world.
pub trait HandWorld {
fn hit(&mut self) -> Hit;
+10 -1
View File
@@ -107,6 +107,7 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_sounds;
mod mod_browser;
#[cfg(feature = "mods")]
mod mod_browser_recipes;
@@ -1525,6 +1526,10 @@ struct Game<'a> {
cart_sounds: HashSet<u32>,
/// The minecart the riding sounds were started for (`LocalPlayer.startRiding`).
riding_cart_sounds: Option<u32>,
/// Bees whose buzz is playing, and whether it is the aggressive one.
bee_sounds: HashMap<u32, bool>,
/// Guardians whose attack hum is playing.
guardian_sounds: HashSet<u32>,
dropped: Vec<ItemEntity>,
next_entity: u32,
particles: horizon_client::particles::ParticleEngine,
@@ -7833,7 +7838,9 @@ impl Game<'_> {
self.yaw
};
for m in &self.mobs {
if m.fire_ticks > 0 && !m.removed {
// Blaze.isOnFire: a charged blaze burns.
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
if (m.fire_ticks > 0 || blazing) && !m.removed {
let at = m.previous.lerp(m.position, alpha);
horizon_client::fire_render::flames(
[at.x, at.y, at.z],
@@ -13653,6 +13660,8 @@ impl<'a> Game<'a> {
sound_paused: false,
cart_sounds: HashSet::new(),
riding_cart_sounds: None,
bee_sounds: HashMap::new(),
guardian_sounds: HashSet::new(),
dropped: Vec::new(),
next_entity: 0,
particles,
@@ -2,8 +2,60 @@
//! shovels) put on a lit campfire puts it out.
use super::Game;
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
use horizon_core::position::BlockPos;
use horizon_world::chunk::BlockState;
use winit::keyboard::KeyCode;
impl Game<'_> {
/// The lit campfire at `pos` that `held` puts out, when the item is in
/// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`).
fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option<BlockState> {
held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
})?;
self.pack
.assets
.registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
}
/// `CampfireBlock.useItemOn` as this client runs it before the server's
/// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of
/// `CampfireBlock.douse` and the campfire gone out, kept as predicted
/// under `seq`. The level event's sound comes from the server. True when
/// it doused (SUCCESS), which ends the click for both hands.
pub(super) fn predict_campfire_douse(
&mut self,
pos: [i32; 3],
held: Option<&ItemStack>,
seq: i32,
) -> bool {
// Player.isSecondaryUseActive: a held item means both hands count.
if self.keys.contains(&KeyCode::ShiftLeft) {
return false;
}
let Some(mut state) = self.campfire_to_douse(pos, held) else {
return false;
};
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
for _ in 0..20 {
make_campfire_smoke(&mut self.particles, pos, signal, true);
}
state.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = self.pack.assets.registry.resolve(&state) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.predict_blocks(&vec![(at, unlit)], seq);
}
true
}
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
pub(super) fn use_campfire_douse(
@@ -11,24 +63,11 @@ impl Game<'_> {
pos: [i32; 3],
held: Option<&ItemStack>,
) -> Result<bool> {
let Some(tool) = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
}) else {
return Ok(false);
};
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held))
else {
return Ok(false);
};
let registry = self.pack.assets.registry;
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
@@ -44,7 +83,6 @@ impl Game<'_> {
if let Ok(unlit) = registry.resolve(&state) {
self.level_set(pos, unlit)?;
}
let used = tool.clone();
self.damage_held(1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &used);
@@ -46,6 +46,10 @@ pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// The players a fishing hook can catch, taken as the hooks tick, and
/// the pulls on them waiting for the hub (game/fishing.rs).
pub hook_targets: Vec<super::fishing::HookTarget>,
pub hook_pulls: Vec<(u32, horizon_core::geometry::Vec3)>,
/// Mace smash knockback on the other players from a guest's turn, waiting
/// for it to end (game/mace.rs).
pub smash_pushes: Vec<super::mace::SmashPush>,
@@ -226,6 +230,8 @@ impl Game<'_> {
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
sleeping: self.sleeping.is_some(),
sleep_timer: self.sleep_timer,
passenger: self.riding.is_some(),
})
}
@@ -1356,6 +1362,22 @@ impl Game<'_> {
[0.0, 0.2, 0.0],
10,
),
MobEvent::DropGift { table, at } => {
let stacks =
self.res
.items()
.loot_table(table, false, None, &mut self.world.random);
for stack in stacks {
// The ItemEntity constructor's random motion.
let r = &mut self.world.random;
let v = [
r.next_double() * 0.2 - 0.1,
0.2,
r.next_double() * 0.2 - 0.1,
];
self.spawn_item(stack, at, v, 10);
}
}
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
@@ -3356,6 +3378,7 @@ impl Game<'_> {
.item_tags
.get("minecraft:parrot_poisonous_food")
.is_some_and(|t| t.contains(&*item));
let player_at = self.eye_position();
let acting = self.acting_player_uuid();
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
@@ -3688,6 +3711,29 @@ impl Game<'_> {
consume = true;
}
}
// Parrot.mobInteract: #parrot_poisonous_food (a cookie) poisons the
// parrot for 45 s and then hurts it with a player attack of
// Float.MAX_VALUE, which an invulnerable one shrugs off.
Species::Parrot
if self
.res
.items()
.item_tags
.get("minecraft:parrot_poisonous_food")
.is_some_and(|t| t.contains(&*item)) =>
{
consume = true;
m.add_effect("minecraft:poison", 900, 0);
let id = m.id;
let hurt = m.damage(
f32::MAX,
horizon_client::mobs::Damage::player(player_at.unwrap_or(center)),
);
// The feeder gets the kill, as for a normal player attack.
if hurt {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
}
// Parrot.mobInteract: parrot food may tame a wild one (one time in
// ten); a parrot never takes it as Animal food (isFood is false).
Species::Parrot if !m.extra.tame && is_food => {
@@ -215,6 +215,106 @@ impl Game<'_> {
}
self.post_attack_bursts(&held, &kind);
}
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
/// `minecraft:post_piercing_attack` effects of the weapon in hand after a
/// jab (Lunge's dash).
pub(super) fn post_piercing_attack(&mut self) {
let Some(held) = self.held().cloned() else {
return;
};
let mut effects = Vec::new();
for (id, level) in held.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
for entry in def.effects["minecraft:post_piercing_attack"]
.as_array()
.into_iter()
.flatten()
{
effects.push((*level, entry["effect"].clone()));
}
}
// Lunge's requirements: not riding, gliding or in water, and a
// player outside creative with at least 7 food.
let Some(p) = self.player.as_ref() else {
return;
};
if effects.is_empty()
|| self.riding.is_some()
|| p.fall_flying
|| p.fluids.was_touching_water
|| !(self.creative || p.health.food.food >= 7)
{
return;
}
for (level, effect) in effects {
self.apply_piercing_effect(&effect, level);
}
}
/// One `post_piercing_attack` effect on the player: `AllOf`,
/// `ChangeItemDamage`, `ApplyExhaustion`, `ApplyEntityImpulse` and
/// `PlaySoundEffect`.
fn apply_piercing_effect(&mut self, effect: &serde_json::Value, level: u32) {
let amount = || level_value(&effect["amount"], level);
match effect["type"].as_str() {
Some("minecraft:all_of") => {
for e in effect["effects"].as_array().into_iter().flatten() {
self.apply_piercing_effect(e, level);
}
}
Some("minecraft:change_item_damage") => self.damage_held(amount() as u32),
Some("minecraft:apply_exhaustion") => {
if let Some(p) = self.player.as_mut() {
p.cause_food_exhaustion(amount());
}
}
// The direction turned by the look (`Entity.getLookQuaternion`:
// pitch about x, then yaw about y), scaled by coordinate_scale
// and the magnitude, added to the velocity (the guest's client
// is told of it by `guest_hit_by`).
Some("minecraft:apply_impulse") => {
let vec3 = |key: &str| [0, 1, 2].map(|i| effect[key][i].as_f64().unwrap_or(0.0));
let (d, scale) = (vec3("direction"), vec3("coordinate_scale"));
let magnitude = f64::from(level_value(&effect["magnitude"], level));
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
f64::from(self.pitch).to_radians(),
);
let (y1, z1) = (
d[1] * pitch.cos() - d[2] * pitch.sin(),
d[1] * pitch.sin() + d[2] * pitch.cos(),
);
let impulse = horizon_core::geometry::Vec3::new(
(d[0] * yaw.cos() - z1 * yaw.sin()) * scale[0] * magnitude,
y1 * scale[1] * magnitude,
(d[0] * yaw.sin() + z1 * yaw.cos()) * scale[2] * magnitude,
);
if let Some(p) = self.player.as_mut() {
p.velocity = p.velocity.add_vector(impulse);
}
}
// The sound for the level (a list picks by level), heard by all.
Some("minecraft:play_sound") => {
let sounds: Vec<&str> = match &effect["sound"] {
serde_json::Value::Array(list) => {
list.iter().filter_map(|s| s.as_str()).collect()
}
one => one.as_str().into_iter().collect(),
};
let index = (level as usize).saturating_sub(1);
if let Some(sound) = sounds.get(index.min(sounds.len().saturating_sub(1))) {
let float = |key: &str| effect[key].as_f64().unwrap_or(1.0) as f32;
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio.play(sound, at, float("volume"), float("pitch"));
}
}
_ => {}
}
}
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
/// random duration and amplifier between the level-based bounds.
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
+99 -4
View File
@@ -1,7 +1,7 @@
//! Fishing: `FishingRodItem.use` (cast / reel in), the `FishingHook` (flight,
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
use super::{Game, view_vector};
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, view_vector};
use horizon_client::advancements::EntityView;
use horizon_client::lan::{entity::States, hub::Hub};
use horizon_client::{
@@ -47,6 +47,13 @@ pub(super) struct Hook {
luck: i32,
lure: i32,
}
/// A player a hook can catch (`canHitEntity`): their entity id, which
/// guest they are (none: the host) and their box.
pub(super) struct HookTarget {
id: u32,
conn: Option<u32>,
bounds: Aabb,
}
const SIZE: f64 = 0.25;
fn bounds(p: Vec3) -> Aabb {
let h = SIZE / 2.0;
@@ -168,6 +175,11 @@ impl Game<'_> {
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
hooked_item = true;
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
// A player: the hub moves them when the tick ends.
self.creatures
.hook_pulls
.push((id, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1)));
}
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
let hooked = self
@@ -476,6 +488,15 @@ impl Game<'_> {
if !self.holds_rod() || d2 > 1024.0 {
return;
}
// The players it can catch, as they stand now (the host's own hook;
// guests' hooks use the ones taken as their turns start).
if self.lan_acting.is_none()
&& matches!(h.state, State::Flying | State::Hooked(_))
&& let Some(host) = self.lan.take()
{
self.refresh_hook_targets(&host);
self.lan = Some(host);
}
h.previous = h.position;
if h.on_ground {
h.life += 1;
@@ -501,8 +522,8 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: hook the first living mob it touches, or (via
// `canHitEntity`) a dropped item.
// checkCollision: hook the first living mob or other player it
// touches, or (via `canHitEntity`) a dropped item.
let bb = bounds(h.position);
let (lo, hi) = (bb.min_position(), bb.max_position());
let touches = |b: Aabb| {
@@ -514,6 +535,14 @@ impl Game<'_> {
.iter()
.find(|m| m.alive() && touches(m.bounds()))
.map(|m| m.id)
.or_else(|| {
let owner = self.lan_acting;
self.creatures
.hook_targets
.iter()
.find(|t| t.conn != owner && touches(t.bounds))
.map(|t| t.id)
})
.or_else(|| {
self.dropped
.iter()
@@ -528,12 +557,25 @@ impl Game<'_> {
}
}
State::Hooked(id) => {
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
// updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height.
let hooked = self
.mobs
.iter()
.find(|m| m.id == id && m.alive())
.map(|m| (m.position, f64::from(m.height())))
.or_else(|| {
self.creatures
.hook_targets
.iter()
.find(|t| t.id == id)
.map(|t| {
let (lo, hi) = (t.bounds.min_position(), t.bounds.max_position());
(
Vec3::new((lo.x + hi.x) / 2.0, lo.y, (lo.z + hi.z) / 2.0),
hi.y - lo.y,
)
})
})
.or_else(|| {
self.dropped
.iter()
@@ -602,6 +644,59 @@ impl Game<'_> {
);
self.hook = Some(h);
}
/// The players a hook can catch: everyone but spectators and the dead
/// (`Projectile.canHitEntity`), the host and the guests in the level.
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
let mut targets = Vec::new();
if self.options.dedicated.is_none()
&& !self.spectator()
&& let Some(p) = self.player.as_ref()
&& !p.health.dead_or_dying()
{
targets.push(HookTarget {
id: host.host_entity as u32,
conn: None,
bounds: p.bounds(),
});
}
for g in &host.hub.guests {
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
targets.push(HookTarget {
id: g.entity_id as u32,
conn: Some(g.conn),
bounds: guest_bounds(g),
});
}
}
self.creatures.hook_targets = targets;
}
/// `FishingHook.pullEntity` on the players the hooks reeled in this tick
/// pulled: their velocity moves, and a guest's reaches their client.
pub(super) fn flush_hook_pulls(&mut self) {
let pulls = std::mem::take(&mut self.creatures.hook_pulls);
let Some(mut host) = self.lan.take() else {
return;
};
for (id, delta) in pulls {
let push = |g: &mut Self| {
if let Some(p) = g.player.as_mut() {
p.velocity = p.velocity.add_vector(delta);
}
};
if id == host.host_entity as u32 {
push(self);
} else if let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.entity_id as u32 == id)
.map(|g| g.conn)
{
self.guest_hit_by(&mut host, conn, push);
}
}
self.lan = Some(host);
}
/// `FishingRodCast`: the stack in the arm holding the rod
/// (`FishingHookRenderer.getHoldingArm`) while the hook is out.
pub(super) fn cast_rod(&self) -> Option<&ItemStack> {
+19 -4
View File
@@ -403,6 +403,12 @@ struct GameHands<'g, 'a> {
error: Option<Box<dyn std::error::Error>>,
}
impl GameHands<'_, '_> {
/// The main-hand item's `kinetic_weapon` and `piercing_weapon`, a spear's.
fn attack_kinetic(&self) -> Option<horizon_client::spear::Kinetic> {
self.game
.held()
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
}
fn keep(&mut self, r: Result<()>) {
if let Err(e) = r
&& self.error.is_none()
@@ -470,18 +476,27 @@ impl HandWorld for GameHands<'_, '_> {
self.game.creative
}
fn cannot_attack(&mut self) -> bool {
false
let ticker = self.game.hand_strength.ticker;
self.game.cannot_attack_with_item(ticker, 0)
}
fn piercing_weapon(&mut self) -> bool {
false
self.attack_kinetic().is_some()
}
fn attack_animation(&mut self) -> SwingAnimation {
SwingAnimation::DEFAULT
self.attack_kinetic()
.map_or(SwingAnimation::DEFAULT, |k| k.attack_animation())
}
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn piercing_attack(&mut self, _: SwingAnimation) {}
fn piercing_attack(&mut self, _: SwingAnimation) {
// The server's part (`PiercingWeapon.attack`), then the client's:
// the sound `MultiPlayerGameMode.piercingAttack` plays.
if let Some(sound) = self.game.jab() {
self.game.play_at_feet(sound);
}
self.game.hand_strength.reset_attack();
}
fn attack_entity(&mut self) -> bool {
self.game.attack()
}
+5 -2
View File
@@ -165,13 +165,16 @@ pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> Owner
..Default::default()
}
}
/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food.
/// `UseDuration` of a stack (`Item.getUseDuration`): 72000 for drawn items
/// and for `blocks_attacks` items (shields), the consume time for food.
fn use_duration(components: &Components) -> i32 {
if let Some(c) = components.get("minecraft:consumable") {
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
return (seconds * 20.0) as i32;
}
if components.get("minecraft:kinetic_weapon").is_some() {
if components.get("minecraft:blocks_attacks").is_some()
|| components.get("minecraft:kinetic_weapon").is_some()
{
return 72_000;
}
let item_model = components
+137 -8
View File
@@ -14,7 +14,7 @@ use horizon_client::{
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, Who, leash::Holder},
mobs::{Mob, Species, Who, leash::Holder},
text::Text,
};
use horizon_core::geometry::Vec3;
@@ -168,6 +168,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
| "minecraft:zombie_villager"
| "minecraft:zombified_piglin"
| "minecraft:hoglin"
| "minecraft:zoglin"
| "minecraft:goat"
| "minecraft:ocelot"
| "minecraft:turtle"
@@ -324,9 +325,13 @@ fn mob_meta(
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, glowing 0x40
// (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6;
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0)
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
| i8::from(m.is_currently_glowing()) << 6;
if shared != 0 {
meta.push((0, serializer::BYTE, Value::Byte(shared)));
}
@@ -336,6 +341,12 @@ fn mob_meta(
if pose != 0 {
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
}
// LivingEntity.DATA_SLEEPING_POS_ID: the bed a sleeping villager lies in.
if horizon_client::mobs::villager::sleeping(m)
&& let Some(bed) = m.extra.bed
{
meta.push((14, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(Some(bed))));
}
if let Some(name) = &m.custom_name {
meta.push((
2,
@@ -430,8 +441,14 @@ fn mob_meta(
}
defaults.push((17, serializer::INT, Value::Int(0)));
}
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
// AbstractPiglin.DATA_IMMUNE_TO_ZOMBIFICATION (16: with the dimension it
// makes it shake) and Piglin.DATA_IS_DANCING (after the baby flag and
// the crossbow charge).
"minecraft:piglin" => {
if e.flag2 {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
if e.scratch[0] > 0 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
@@ -485,6 +502,15 @@ fn mob_meta(
if let Some(v) = id(registry, name) {
meta.push((index, serializer, Value::Id(v)));
}
// Cat.IS_LYING and RELAX_STATE_ONE.
if kind == "minecraft:cat" {
for (index, on) in [(21, e.flag), (22, e.relax_one)] {
if on {
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
}
}
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
let red = i32::from(horizon_client::mobs::tame::RED);
@@ -509,10 +535,16 @@ fn mob_meta(
| "minecraft:skeleton_horse"
| "minecraft:zombie_horse"
| "minecraft:camel" => {
// AbstractHorse.DATA_ID_FLAGS: tame 2.
if e.tame {
meta.push((18, serializer::BYTE, Value::Byte(2)));
// AbstractHorse.DATA_ID_FLAGS: tame 2, eating 16 (grazing), standing
// 32 (rearing).
let grazes = horizon_client::mobs::equine::grazes(m.species());
let flags = if e.tame { 2 } else { 0 }
| if grazes && e.flag { 16 } else { 0 }
| if grazes && e.counter > 0 { 32 } else { 0 };
if flags != 0 {
meta.push((18, serializer::BYTE, Value::Byte(flags)));
}
defaults.push((18, serializer::BYTE, Value::Byte(0)));
if kind == "minecraft:horse" {
let v = i32::from(e.variant) | i32::from(e.variant2) << 8;
if v != 0 {
@@ -545,6 +577,15 @@ fn mob_meta(
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
"minecraft:villager" | "minecraft:zombie_villager" => {
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
if kind == "minecraft:villager" {
unhappy_counter(m, meta, defaults);
} else {
// ZombieVillager.DATA_CONVERTING_ID: the cure's shaking.
if horizon_client::mobs::zombie_villager::is_converting(m) {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
let villager_type = VILLAGER_TYPES[e.variant as usize % VILLAGER_TYPES.len()];
let profession = PROFESSIONS[e.variant2 as usize % PROFESSIONS.len()];
if let (Some(t), Some(p)) = (
@@ -752,6 +793,60 @@ fn mob_meta(
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
defaults.push((16, serializer::OPTIONAL_BLOCK_STATE, Value::Id(0)));
}
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
// DATA_SPECIAL_TYPE_ID): the shaking of the conversion under water.
"minecraft:zombie" | "minecraft:husk" => {
if e.counter2 > 0 {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
// AbstractPiglin's, the first entry of its subclasses (the piglin's is
// in its own arm): with the dimension it makes them shake.
"minecraft:hoglin" | "minecraft:piglin_brute" => {
let index = if kind == "minecraft:hoglin" { 18 } else { 16 };
if e.flag2 {
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
}
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
"minecraft:blaze" => {
if e.flag {
meta.push((16, serializer::BYTE, Value::Byte(1)));
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
"minecraft:turtle" => {
if e.flag {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
if e.flag2 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// WitherBoss.DATA_TARGET_B and DATA_TARGET_C (after DATA_TARGET_A, the
// main target): the entities the side heads look at, by id.
"minecraft:wither" => {
for (k, index) in [(0, 17), (1, 18)] {
let target = match e.ints[k] {
0 => None,
-1 => player_entity,
id => Some(id),
};
if let Some(target) = target {
meta.push((index, serializer::INT, Value::Int(target)));
}
defaults.push((index, serializer::INT, Value::Int(0)));
}
}
_ => {}
}
@@ -769,6 +864,19 @@ fn mob_meta(
}
}
/// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two): the head
/// shake of a villager saying no counts it down from 40.
fn unhappy_counter(
m: &Mob,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
if m.extra.ints[0] > 0 {
meta.push((18, serializer::INT, Value::Int(m.extra.ints[0])));
}
defaults.push((18, serializer::INT, Value::Int(0)));
}
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
/// AgeableMob's two and DATA_BOOST_TIME).
@@ -1603,6 +1711,8 @@ impl Game<'_> {
let _ = self.flush_wind_bursts();
// A sweep attack's reach to the other players from a guest's attack.
self.flush_sweeps();
// A reeled-in hook pulling a player.
self.flush_hook_pulls();
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
self.flush_lan_particles();
}
@@ -1743,6 +1853,11 @@ impl Game<'_> {
if self.audio.capture.is_some() {
self.lan_entity_events.push((id, event, at));
}
// Event 21: a window that simulates the world itself is its own
// client, and hears the guardian's hum too.
if event == 21 && self.options.execution_role.is_client() {
self.start_guardian_sound(id as u32);
}
}
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
/// emitter, and an animation for the attacker's client and observers once
@@ -2019,6 +2134,8 @@ impl Game<'_> {
.get(g.selected)
.and_then(|s| s.as_ref())
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
m.extra.main_hand_left = g.info.main_hand == 0;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
m.extra.effect_particles = visible()
@@ -2043,6 +2160,18 @@ impl Game<'_> {
let using = host.play.get(&g.conn).and_then(|p| p.using());
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
// item with `blocks_attacks`) raises the arm.
m.extra.blocking = using
.and_then(|(slot, _)| {
if slot == OFFHAND_SLOT {
m.extra.off_hand.as_ref()
} else {
m.extra.main_hand.as_ref()
}
})
.and_then(|i| self.res.items().get(i))
.is_some_and(|i| i.blocks_attacks.is_some());
}
}
+39 -1
View File
@@ -1133,6 +1133,8 @@ impl Game<'_> {
g.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
});
}
// STAB: `PiercingWeapon.attack` of a left click with a spear.
8 if !spectator => self.guest_jab(host, conn),
_ => {}
},
S::SetCarriedItem(p) => {
@@ -1676,6 +1678,37 @@ impl Game<'_> {
Some((centre, eye))
}
/// `ServerGamePacketListenerImpl.handlePlayerAction` for STAB: the spear in
/// the guest's main hand jabs once its charge allows it (with five ticks
/// of tolerance). The guest's client is sent the dash Lunge gave them.
fn guest_jab(&mut self, host: &mut LanHost, conn: u32) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let at = g.position;
// ServerPlayer.getKnownMovement: the move their client just sent.
let movement = g
.moves
.last()
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
let sound = self
.guest_hit_by(host, conn, |g| {
g.known_movement = movement;
if g.cannot_attack_with_item(g.attack_ticks as i32, 5) {
None
} else {
g.jab()
}
})
.flatten();
// PiercingWeapon.makeSound: `playSound(causer, ...)`, so the guest's
// own client plays it.
if let Some(name) = sound {
let source = horizon_client::settings::sound_source(name) as i32;
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
}
}
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
@@ -2081,6 +2114,10 @@ impl Game<'_> {
.filter(|g| host.hub.here(g) && g.loaded)
.map(|g| g.conn)
.collect();
// The players the guests' hooks can catch.
if host.play.values().any(|p| p.hook.is_some()) {
self.refresh_hook_targets(host);
}
for conn in conns {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
continue;
@@ -3160,7 +3197,8 @@ impl Game<'_> {
let packet = swing_packet(id, c.hand, c.animation);
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
if let Some(m) = host.models.get_mut(&conn) {
m.swing();
// The duration already counts the guest's haste or fatigue.
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
}
}
play.swing.tick();
@@ -26,6 +26,20 @@ pub(super) fn player_for(
guests: &GuestViews,
bound: Option<u32>,
) -> Option<u32> {
// A tame cat reads its owner wherever they are while they sleep, or just
// woke up (`CatRelaxOnOwnerGoal` uses `getOwner`, not the nearest player).
if m.species() == Species::Cat
&& m.extra.tame
&& let Some(owner) = m.extra.owner
{
let asleep = |v: &PlayerView| v.uuid == owner && (v.sleeping || v.sleep_timer > 0);
if host.is_some_and(asleep) {
return None;
}
if let Some((conn, _)) = guests.iter().find(|(_, v)| asleep(v)) {
return Some(*conn);
}
}
let targeting = m.ai.target == Some(Who::Player)
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
// A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they
@@ -399,6 +413,8 @@ mod tests {
uuid: [0; 4],
allies: Default::default(),
remote: false,
sleeping: false,
sleep_timer: 0,
passenger: false,
}
}
@@ -0,0 +1,82 @@
//! The looping sound instances of mobs: a bee's buzz
//! (`ClientPacketListener.postAddEntitySoundInstance`) and a guardian's
//! attack hum (entity event 21). The sound engine ticks them with what
//! `start_mob_sounds` reads off the mobs.
use super::Game;
use horizon_client::{
mobs::{Species, bee, guardian},
sound_engine::{MobSound, SoundEngine, SoundInstance, Sounds},
};
impl Game<'_> {
/// Start the buzz of each bee that has none (or whose anger changed, which
/// swaps the flying loop for the aggressive one and back), forget the mobs
/// that are gone, and read what the instances follow.
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
let game_time = self.world.game_time;
let mobs: Vec<MobSound> = self
.mobs
.iter()
.filter(|m| {
!m.removed
&& matches!(
m.species(),
Species::Bee | Species::Guardian | Species::ElderGuardian
)
})
.map(|m| MobSound {
id: m.id,
position: [m.position.x, m.position.y, m.position.z],
// Vec3.horizontalDistance.
speed: m.velocity.x.hypot(m.velocity.z),
baby: m.is_baby(),
// NeutralMob.isAngry: the anger end time is ahead.
angry: m.species() == Species::Bee
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
silent: m.silent,
attack_scale: guardian::attack_scale(m),
})
.collect();
self.guardian_sounds
.retain(|id| mobs.iter().any(|m| m.id == *id));
self.bee_sounds
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
for sound in &mobs {
let is_bee = self
.mobs
.iter()
.any(|m| m.id == sound.id && m.species() == Species::Bee);
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
self.bee_sounds.insert(sound.id, sound.angry);
let instance = SoundInstance::bee(sound, sound.angry, SoundEngine::unseeded());
self.audio.play_instance(instance, false);
}
}
mobs
}
/// `ClientPacketListener.handleEntityEvent` 21: a guardian's attack
/// hum starts (one loop per guardian; the loops of its earlier beams are
/// the same loop, as each one follows the guardian's charge).
pub(super) fn start_guardian_sound(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| {
m.id == id && matches!(m.species(), Species::Guardian | Species::ElderGuardian)
}) else {
return;
};
if !self.guardian_sounds.insert(id) {
return;
}
let sound = MobSound {
id,
position: [m.position.x, m.position.y, m.position.z],
speed: 0.0,
baby: false,
angry: false,
silent: m.silent,
attack_scale: guardian::attack_scale(m),
};
let instance = SoundInstance::guardian_attack(&sound, SoundEngine::unseeded());
self.audio.play_instance(instance, false);
}
}
+2
View File
@@ -34,11 +34,13 @@ impl Game<'_> {
self.sound_paused = self.pause;
}
let (vehicle, carts) = self.start_cart_sounds();
let mobs = self.start_mob_sounds();
let ctx = TickContext {
player_underwater: self.ambience.was_underwater(),
player_removed: self.player.is_none(),
vehicle,
carts,
mobs,
frozen: !self.clock.runs_normally(),
};
self.audio.tick(self.pause, &ctx);
+114 -5
View File
@@ -2097,6 +2097,13 @@ impl Game<'_> {
m.extra.ints[1] = code;
}
}
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
// right): the arm a swing of the main hand moves.
if let (15, Value::Id(arm)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
m.extra.main_hand_left = *arm == 0;
}
}
}
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
@@ -2321,6 +2328,10 @@ impl Game<'_> {
if p.event == 69 {
self.remote_ravager_roar(p.id);
}
// ClientPacketListener.handleEntityEvent 21: a guardian's hum.
if p.event == 21 {
self.start_guardian_sound(p.id as u32);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
@@ -2421,7 +2432,7 @@ impl Game<'_> {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.swing();
m.swing_hand_modified(hand == Hand::Off, animation.duration);
}
}
// The player's own instances: its maximum health (the hearts the
@@ -3928,6 +3939,34 @@ impl Game<'_> {
}
}
let game_time = self.world.game_time;
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
let nether = self.dim == super::Dim::Nether;
// What a wither's side heads can look at, by network entity id: the
// mobs and the player (position and eye height).
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
.mobs
.iter()
.any(|m| m.species() == horizon_client::mobs::Species::Wither)
{
let own = self
.remote
.as_ref()
.zip(self.eye_position())
.map(|(r, eye)| (r.entity_id, eye, eye[1]));
self.mobs
.iter()
.map(|m| {
(
m.id as i32,
[m.position.x, m.position.y, m.position.z],
m.eye_y(),
)
})
.chain(own)
.collect()
} else {
Vec::new()
};
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -4014,6 +4053,11 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Fox {
horizon_client::mobs::fox::client_tick(m);
}
// Cat.tick: the lying and relaxing amounts follow the synched flags.
if m.species() == horizon_client::mobs::Species::Cat {
m.anim.vo = m.anim.v;
horizon_client::mobs::tame::cat_amounts(m);
}
// Chicken.aiStep: the wings follow the server's on-ground flag.
if m.species() == horizon_client::mobs::Species::Chicken {
horizon_client::mobs::farm::chicken_flap(m);
@@ -4021,6 +4065,20 @@ impl Game<'_> {
// LivingEntity.updatingUsingItem: the ticks the item has been
// used before this one. Another player's use particles; a mob's
// drawn bow only needs the count (its pull follows it).
if player {
// AvatarRenderer.getArmPose: the used item's BLOCK animation
// (an item with `blocks_attacks`) raises the arm.
let used = if m.extra.using_off_hand {
&m.extra.off_hand
} else {
&m.extra.main_hand
};
m.extra.blocking = m.extra.using_item > 0
&& used
.as_ref()
.and_then(|i| self.res.items().get(i))
.is_some_and(|i| i.blocks_attacks.is_some());
}
if m.extra.using_item > 0 {
if player {
let item = if m.extra.using_off_hand {
@@ -4067,8 +4125,26 @@ impl Game<'_> {
}
// The animation states a server computes in its own `ai_step`.
horizon_client::mobs::sync::client_tick(m, game_time);
if m.species() == horizon_client::mobs::Species::Panda {
horizon_client::mobs::panda::client_tick(m, thundering);
match m.species() {
// WitherBoss.aiStep: the side heads turn toward their targets.
horizon_client::mobs::Species::Wither => {
horizon_client::mobs::wither::client_tick(m, |id| {
wither_targets
.iter()
.find(|t| t.0 == id)
.map(|t| (t.1, t.2))
});
}
// Hoglin and piglin `isConverting` outside the Nether.
horizon_client::mobs::Species::Hoglin
| horizon_client::mobs::Species::Piglin
| horizon_client::mobs::Species::PiglinBrute => {
horizon_client::mobs::piglin::client_converting(m, nether);
}
horizon_client::mobs::Species::Panda => {
horizon_client::mobs::panda::client_tick(m, thundering);
}
_ => {}
}
}
for o in self.orbs.iter_mut().filter(|_| running) {
@@ -4541,6 +4617,11 @@ impl Game<'_> {
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
// CampfireBlock.useItemOn succeeded: this hand swings and the
// click ends, so a shield in the other hand is not raised.
self.with_hand(hand, Game::swing_arm);
return true;
} else if held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
// server brushes and the dust is this client's own.
@@ -4727,7 +4808,7 @@ impl Game<'_> {
/// predicted under the use's sequence until the server acknowledges it
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
/// server refused goes back.
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
pub(super) fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
@@ -5115,6 +5196,28 @@ impl Game<'_> {
if self.spectator() {
return true;
}
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
// stabs, this client swings, plays the sound and starts its charge
// again.
if let Some(kinetic) = self
.held()
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
{
if self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
return false;
}
// ServerboundPlayerActionPacket.Action.STAB.
self.send(S::PlayerAction(play::PlayerAction {
action: 8,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
self.hand_strength.reset_attack();
self.swing_hand(Hand::Main, kinetic.attack_animation());
self.play_at_feet(kinetic.attack_sound);
return true;
}
let mut instant = false;
// A ghast's fireball or a wind charge nearer than any mob: the
// server turns it (game/deflect.rs).
@@ -5165,7 +5268,13 @@ impl Game<'_> {
if !down {
self.clicks.miss_time = 0;
}
if self.clicks.miss_time > 0 || self.using.is_some() {
// A piercing weapon neither breaks blocks nor swings on its own.
if self.clicks.miss_time > 0
|| self.using.is_some()
|| self
.held()
.is_some_and(|s| horizon_client::spear::Kinetic::of(&s.item).is_some())
{
return;
}
let entity = self.entity_targeted();
@@ -5,11 +5,13 @@
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{
Mob, Species, Who, armadillo, bat, bee, camel, creaking, finalize, panda, sniffer, sync, tame,
Mob, Species, Who, armadillo, bat, bee, camel, creaking, equine, finalize, illusioner, panda,
sniffer, sync, tame, zombie_villager,
};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
use horizon_protocol::registry::{Builtin, Dynamic};
use horizon_world::registry::BlockStateId;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
@@ -31,7 +33,21 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
// client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
(0, Value::Byte(flags)) => {
m.fire_ticks = i32::from(flags & 1);
// Drowned.setSwimming: the shared flag of swimming.
if species == Species::Drowned {
m.extra.flag = flags & 16 != 0;
}
// The shared flag of invisibility: Entity.isInvisible hides
// the model (an illusioner shows its mirror images).
let invisible = flags & 32 != 0;
if species == Species::Illusioner {
illusioner::set_invisible(m, invisible);
} else if &*m.kind.id != "minecraft:player" {
m.invisible = invisible;
}
}
// Entity.DATA_POSE: the pose the species animates by.
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
@@ -112,6 +128,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
m.extra.sitting = flags & 1 != 0;
m.extra.tame = flags & 4 != 0;
}
// Cat.IS_LYING and RELAX_STATE_ONE.
(Species::Cat, 21, Value::Bool(lying)) => m.extra.flag = *lying,
(Species::Cat, 22, Value::Bool(relaxing)) => m.extra.relax_one = *relaxing,
// Wolf.DATA_COLLAR_COLOR and Cat's.
(Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => {
m.extra.color = (*c & 15) as u8;
@@ -215,6 +234,42 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
(Species::CopperGolem, 16, Value::Id(state)) => {
m.extra.variant = (*state).clamp(0, 3) as u8;
}
// AbstractHorse.DATA_ID_FLAGS: eating 16 and standing 32 (the
// graze and the rear), which `sync::client_tick` animates.
(s, 18, Value::Byte(flags)) if equine::grazes(s) => equine::set_flags(m, *flags as u8),
// Enderman.DATA_CARRY_STATE (0 for none): the block it holds.
(Species::Enderman, 16, Value::Id(state)) => {
m.extra.carried = (*state > 0).then_some(BlockStateId(*state as u32));
}
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
// DATA_SPECIAL_TYPE_ID): the conversion's shaking.
(Species::Zombie | Species::Husk, 18, Value::Bool(converting)) => {
m.extra.counter2 = i32::from(*converting);
}
// ZombieVillager.DATA_CONVERTING_ID (before its villager data).
(Species::ZombieVillager, 19, Value::Bool(converting)) => {
zombie_villager::set_converting(m, *converting);
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two):
// the head shake of `no`, which the server counts down.
(Species::Villager | Species::WanderingTrader, 18, Value::Int(ticks)) => {
m.extra.ints[0] = *ticks;
}
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
// AbstractPiglin's: `sync`'s conversion shake reads it.
(Species::Hoglin, 18, Value::Bool(immune))
| (Species::Piglin | Species::PiglinBrute, 16, Value::Bool(immune)) => {
m.extra.flag2 = *immune;
}
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
(Species::Blaze, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
(Species::Turtle, 18, Value::Bool(has_egg)) => m.extra.flag = *has_egg,
(Species::Turtle, 19, Value::Bool(laying)) => m.extra.flag2 = *laying,
// WitherBoss.DATA_TARGET_B and _C (after DATA_TARGET_A): the
// entities the side heads turn toward in `wither::client_tick`.
(Species::Wither, 17, Value::Int(id)) => m.extra.ints[0] = *id,
(Species::Wither, 18, Value::Int(id)) => m.extra.ints[1] = *id,
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
// IS_CELEBRATING takes 16).
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
@@ -357,9 +412,20 @@ impl Game<'_> {
let Some(r) = &self.remote else {
return;
};
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): a villager lies
// in its bed the way the bed faces, or gets up.
let bed = values.iter().find_map(|v| match v {
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
_ => None,
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
apply(m, values, &r.builtin, r.entity_id);
apply_registry_data(m, values, &r.registries, self.world.game_time);
if let Some(code) = bed
&& m.species() == Species::Villager
{
m.extra.ints[1] = code;
}
}
}
}
+89 -19
View File
@@ -5,10 +5,13 @@
//! attacker's own speed, and each condition runs out after its time
//! (`Item.use`, `ItemStack.onUseTick`, `KineticWeapon.damageEntities`,
//! `Player.stabAttack`). The hit's feedback (`onKineticHit`) reaches the
//! players around as entity event 2.
//! players around as entity event 2. A left click jabs instead
//! (`PiercingWeapon.attack`), which Lunge turns into a dash.
use super::{Game, view_vector};
use horizon_client::{
attack_strength::delay,
collisions::SceneCollisions,
first_person::Hand,
items::ItemStack,
mobs::Damage,
spear::{HIT_FEEDBACK_TICKS, Kinetic, REACH},
@@ -115,6 +118,28 @@ impl Game<'_> {
hits
}
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`: the
/// mobs along the view from the minimum reach to the maximum plus the
/// attacker's own movement, none when a block is closer than the minimum.
fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec<usize> {
// AttackRange.effectiveMinRange and effectiveMaxRange for a player.
let (min, max) = if self.creative {
(REACH.min_creative, REACH.max_creative)
} else {
(REACH.min, REACH.max)
};
let from = eye.add_vector(look.scale(f64::from(min)));
let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0)));
// A block closer than the minimum reach takes the whole stab.
if let Some(block) = self.clip_collider(eye, to) {
to = block;
if eye.distance_squared(to) < eye.distance_squared(from) {
return Vec::new();
}
}
self.mobs_along(from, to, f64::from(REACH.hitbox_margin))
}
/// `KineticWeapon.damageEntities`: one tick of the spear in use, `ticks`
/// ticks into the use.
pub(super) fn spear_tick(&mut self, stack: &ItemStack, ticks: u32) {
@@ -136,25 +161,10 @@ impl Game<'_> {
let look = Vec3::new(look[0], look[1], look[2]);
let movement = self.known_player_movement();
let attacker_speed = look.dot(movement.scale(20.0));
// AttackRange.effectiveMinRange and effectiveMaxRange for a player.
let (min, max) = if self.creative {
(REACH.min_creative, REACH.max_creative)
} else {
(REACH.min, REACH.max)
};
let from = eye.add_vector(look.scale(f64::from(min)));
let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0)));
// A block closer than the minimum reach takes the whole stab.
if let Some(block) = self.clip_collider(eye, to) {
to = block;
if eye.distance_squared(to) < eye.distance_squared(from) {
return;
}
}
let now = self.world.game_time;
let actor = self.lan_acting;
let mut affected = false;
for i in self.mobs_along(from, to, f64::from(REACH.hitbox_margin)) {
for i in self.stab_targets(eye, look, movement) {
let key = (actor, self.mobs[i].id);
let recent = self
.stabbed
@@ -204,9 +214,18 @@ impl Game<'_> {
return false;
};
let from = [p.position.x, p.position.y, p.position.z];
// The enchantments' share, with no attack strength scaling for a
// weapon in use.
// The enchantments' share, scaled by the attack strength unless the
// weapon is in use.
let magic_boost = self.enchant_damage_bonus(&kind);
let (base_damage, magic_boost) = if self.using.is_none() {
let scale = self.attack_strength_scale();
(
base_damage * (0.2 + scale * scale * 0.8),
magic_boost * scale,
)
} else {
(base_damage, magic_boost)
};
let total = if deals_damage {
base_damage + magic_boost
} else {
@@ -275,6 +294,57 @@ impl Game<'_> {
true
}
/// `Player.cannotAttackWithItem` for the held item: a spear needs its
/// `minimum_attack_charge` of 1, counting `tolerance` ticks more than the
/// `ticker` ticks charged.
pub(super) fn cannot_attack_with_item(&self, ticker: i32, tolerance: i32) -> bool {
self.held().is_some_and(|s| Kinetic::of(&s.item).is_some())
&& (ticker + tolerance) as f32 / delay(self.main_hand_attack_speed()) < 1.0
}
/// `PiercingWeapon.attack` of the spear in the main hand, for a left
/// click: every mob along the view is stabbed, then `postPiercingAttack`
/// (Lunge), the hit sound and the swing. Gives the sound of the jab
/// (`makeSound`), which everyone but the user hears from the server.
pub(super) fn jab(&mut self) -> Option<&'static str> {
let kinetic = self.held().and_then(|s| Kinetic::of(&s.item))?;
let eye = self.eye_position()?;
let eye = Vec3::new(eye[0], eye[1], eye[2]);
let look = view_vector(self.yaw, self.pitch);
let look = Vec3::new(look[0], look[1], look[2]);
// The attack damage attribute.
let damage = 1.0
+ self.attack_item_info().map_or(0.0, |i| i.attack_damage)
+ self.effect_attack_bonus();
let movement = self.known_player_movement();
let mut hit = false;
for i in self.stab_targets(eye, look, movement) {
hit |= self.stab_mob(i, damage, true, true, false);
}
// Player.onAttack, then postPiercingAttack.
self.attack_ticks = 0;
self.post_piercing_attack();
if hit {
self.play_at_feet(kinetic.hit_sound);
}
// swingAndResetAttackStrength (ServerPlayer): a swing that starts
// resets the attack strength.
let swings = self.swings_started;
self.swing_hand(Hand::Main, kinetic.attack_animation());
if self.swings_started != swings {
self.reset_attack_strength();
}
Some(kinetic.attack_sound)
}
/// A sound at the player's feet.
pub(super) fn play_at_feet(&mut self, sound: &str) {
if let Some(p) = &self.player {
let at = [p.position.x, p.position.y, p.position.z];
self.audio.play(sound, Some(at), 1.0, 1.0);
}
}
/// `LivingEntity.onKineticHit` for `entity`: the hit feels (the spear
/// moves back, `kinetic_hit_ticks`) and sounds, at most once every ten
/// ticks.
+7 -7
View File
@@ -22,12 +22,10 @@ pub mod block_entity_anim;
pub mod block_entity_item;
pub mod block_entity_render;
pub mod block_transform;
pub mod blocks;
pub mod border_render;
pub mod boss_overlay;
pub mod breaking;
pub mod brewing;
pub mod bundle;
pub mod camera;
pub mod cauldron;
pub mod chat;
@@ -43,7 +41,6 @@ pub mod confirm_screen;
pub mod container_screen;
pub mod containers;
pub mod control;
pub mod cooldowns;
pub mod crafting;
pub mod crash;
pub mod creative;
@@ -58,7 +55,6 @@ pub mod dialog_screen;
pub mod discord_presence;
pub mod dynamic_light;
pub mod effects;
pub mod enchant;
pub mod end_crystal;
pub mod entities;
pub mod entity_culling;
@@ -94,11 +90,9 @@ pub mod input;
pub mod interaction_input;
pub mod inv_sort;
pub mod inventory_effects;
pub mod item_data;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod items;
pub mod java_text;
pub mod key_names;
pub mod keybinds;
@@ -148,7 +142,6 @@ pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod random_sequences;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
@@ -210,3 +203,10 @@ pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
+16 -1
View File
@@ -706,6 +706,8 @@ pub struct Extra {
pub body_damage: i32,
/// The components (dye, name...) of that armor stack.
pub body_extra: Option<Box<crate::item_data::StackData>>,
/// `Cat.RELAX_STATE_ONE`: watching a sleeping owner before lying down.
pub relax_one: bool,
/// The saddle's components (name, enchantments...) when it is not a bare
/// one; `saddled` stays the gameplay flag.
pub saddle_stack: Option<crate::items::ItemStack>,
@@ -963,7 +965,8 @@ pub enum MobEvent {
mob: u32,
at: [f64; 3],
},
/// `Level.broadcastEntityEvent` for a mob: guests run `sync::entity_event`.
/// `Level.broadcastEntityEvent` for a mob: guests run its
/// `handleEntityEvent`.
EntityEvent {
mob: u32,
event: i8,
@@ -1155,6 +1158,11 @@ pub enum MobEvent {
count: u32,
at: [f64; 3],
},
/// `Mob.dropFromGiftLootTable`: a gift loot table's items dropped at `at`.
DropGift {
table: &'static str,
at: [f64; 3],
},
SetBlock {
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
@@ -1483,6 +1491,8 @@ pub struct Seen {
pub sitting: bool,
/// `Entity.isSilent`.
pub silent: bool,
/// `Cat.isLying` or `isRelaxStateOne`.
pub lying: bool,
/// `Entity.isVehicle` or `isPassenger`.
pub ridden: bool,
/// `Attributes.KNOCKBACK_RESISTANCE`.
@@ -1578,6 +1588,7 @@ impl Mob {
],
sitting: m.extra.sitting,
silent: m.silent,
lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one),
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
knockback_resistance: m.attrs.knockback_resistance,
effects: m
@@ -1748,6 +1759,10 @@ 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,
/// `isSleeping` and `getSleepTimer` (100 once slept through, then the
/// ticks fading out after waking up).
pub sleeping: bool,
pub sleep_timer: u32,
/// `isPassenger`.
pub passenger: bool,
}
+57 -6
View File
@@ -53,17 +53,62 @@ pub struct Ai {
/// Swing animation progress (attackAnim) and ticks.
pub swing_time: i32,
pub swinging: bool,
/// The current swing's `SwingDescription.durationTicks`; 0 is the default 6.
pub swing_duration: i32,
/// Walked distance and the next footstep.
pub steps: super::living::Steps,
}
impl Mob {
/// `LivingEntity.getCurrentSwingDuration`: 6 ticks unless a swing set its own.
pub fn swing_duration(&self) -> i32 {
if self.ai.swing_duration > 0 {
self.ai.swing_duration
} else {
6
}
}
/// `SwingState.startIfAble`: a swing past the first half of the current
/// one restarts it.
fn start_swing(&mut self, duration: i32) -> bool {
let half = self.swing_duration() / 2;
if self.ai.swinging && self.ai.swing_time >= 0 && self.ai.swing_time < half {
return false;
}
self.ai.swing_time = -1;
self.ai.swinging = true;
self.ai.swing_duration = duration.max(1);
true
}
/// LivingEntity.swing
pub fn swing(&mut self) {
if !self.ai.swinging || self.ai.swing_time >= 3 || self.ai.swing_time < 0 {
self.ai.swing_time = -1;
self.ai.swinging = true;
if self.start_swing(6) {
self.extra.swing_right_arm = None;
}
}
/// `LivingEntity.swing(hand, animation, false)` for a swing of `duration`
/// ticks (`getModifiedSwingDuration`): the arm is `hand.asArm(mainArm)`.
pub fn swing_hand(&mut self, off_hand: bool, duration: i32) {
if self.start_swing(duration) {
self.extra.swing_right_arm = Some(!off_hand != self.main_arm_left());
}
}
/// `swing_hand` for a swing of `base` ticks, lengthened or shortened by
/// this mob's haste, conduit power or mining fatigue
/// (`LivingEntity.getModifiedSwingDuration`).
pub fn swing_hand_modified(&mut self, off_hand: bool, base: i32) {
let amplifier = |id: &str| self.extra.effects.iter().find(|e| e.0 == id).map(|e| e.2);
let dig_speed = amplifier("minecraft:haste").max(amplifier("minecraft:conduit_power"));
let fatigue = amplifier("minecraft:mining_fatigue");
let duration = crate::first_person::swing_duration(
crate::first_person::SwingAnimation {
kind: 1,
duration: base,
},
dig_speed,
fatigue,
);
self.swing_hand(off_hand, duration);
}
}
/// The module implementing a species' behaviour.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -540,18 +585,21 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
}
}
}
/// LivingEntity.updateSwingTime (6-tick swing duration).
/// LivingEntity.updateSwingTime.
pub fn tick_swing(m: &mut Mob) {
let duration = m.swing_duration();
if m.ai.swinging {
m.ai.swing_time += 1;
if m.ai.swing_time >= 6 {
if m.ai.swing_time >= duration {
m.ai.swing_time = 0;
m.ai.swinging = false;
m.ai.swing_duration = 0;
m.extra.swing_right_arm = None;
}
} else {
m.ai.swing_time = 0;
}
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
m.anim.attack_anim = m.ai.swing_time as f32 / duration as f32;
}
/// `Entity.isInRain`: rain at the block position, or at the top of the box.
pub(super) fn in_rain(m: &Mob, w: &MobWorld) -> bool {
@@ -587,6 +635,9 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.was_eye_in_water = m.fluids.eye_in_water;
m.update_fluids(w, events);
if m.species() == Species::Drowned {
super::undead::drowned_update_swimming(m, w);
}
let fire = crate::motion::environment::fire_contact(w.world, w.registry, m.bounds());
if fire > 0.0 {
m.ticks_frozen = 0;
+23
View File
@@ -540,6 +540,26 @@ fn rot_lerp_rad(a: f32, from: f32, to: f32) -> f32 {
}
from + a * diff
}
/// `DrownedModel.setupSwimAnimation`: the arms reach up and sway, the legs
/// kick and the head lies straight.
fn drowned_swim(p: &mut Poses, s: &State, amount: f32) {
if amount <= 0.0 {
return;
}
let sway = amount * 0.35 * (0.1 * s.age).msin();
let kick = amount * 0.55 * (0.1 * s.age).msin();
let reach = -PI * 4.0 / 5.0;
let (right_x, left_x) = (p.get("right_arm")[3], p.get("left_arm")[3]);
let (right_z, left_z) = (p.get("right_arm")[5], p.get("left_arm")[5]);
p.xrot("right_arm", rot_lerp_rad(amount, right_x, reach) + sway);
p.xrot("left_arm", rot_lerp_rad(amount, left_x, reach) - sway);
p.zrot("right_arm", rot_lerp_rad(amount, right_z, -0.15));
p.zrot("left_arm", rot_lerp_rad(amount, left_z, 0.15));
let (left_leg, right_leg) = (p.get("left_leg")[3], p.get("right_leg")[3]);
p.xrot("left_leg", left_leg - kick);
p.xrot("right_leg", right_leg + kick);
p.xrot("head", 0.0);
}
/// `HumanoidModel.setupSwimAnimation`: the stroke of both arms over 26
/// walk-animation units, the head down and a slow flutter kick. The left arm
/// eases the short way round, the right one linearly, as in vanilla.
@@ -697,6 +717,9 @@ pub fn setup(
h.crouching = m.extra.crouching;
h.passenger = m.extra.passenger;
humanoid(p, s, &h);
if m.kind.anim == Anim::Drowned {
drowned_swim(p, s, m.extra.swim.amount(s.partial));
}
if m.kind.anim == Anim::ZombifiedPiglin {
super::piglin::ears(p, s);
}
+21
View File
@@ -471,6 +471,11 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.extra.counter > 0 {
m.extra.counter -= 1;
}
animate(m);
}
/// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the
/// eating and standing flags (`flag` and `counter > 0`).
pub fn animate(m: &mut Mob) {
let (eating, standing) = (m.extra.flag, m.extra.counter > 0);
let v = &mut m.anim.v;
if standing {
@@ -488,6 +493,22 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
v[MOUTH] = (v[MOUTH] - v[MOUTH] * 0.7 - 0.05).max(0.0);
}
/// `AbstractHorse.tick` on a client that is not the server: the animations
/// follow the flags of the synched data (`set_flags`).
pub fn client_tick(m: &mut Mob) {
m.anim.vo = m.anim.v;
animate(m);
}
/// `AbstractHorse.DATA_ID_FLAGS` on a client: eating 16 and standing 32.
pub fn set_flags(m: &mut Mob, flags: u8) {
m.extra.flag = flags & 16 != 0;
m.extra.counter = i32::from(flags & 32 != 0);
}
/// Whether `ai_step` above runs this species' grazing and rearing, so that
/// the server's flags 16 and 32 mean eating and standing.
pub fn grazes(s: Species) -> bool {
is_equine(s) && !super::camel::is_camel(s) && !super::llama::is_llama(s)
}
/// Body texture and the markings overlay.
pub fn texture(m: &Mob) -> String {
let baby = if m.is_baby() { "_baby" } else { "" };
+17 -8
View File
@@ -112,14 +112,14 @@ impl Goal for GuardianAttack {
if self.attack_time == 0 {
m.extra.flag = true;
m.extra.counter2 = 0;
// Entity event 21: the attack sound loop starts.
let sound = "entity.guardian.attack";
cx.events.push(MobEvent::Sound(
sound,
[m.position.x, m.position.y, m.position.z],
1.0,
1.0,
));
// Entity event 21: the attack sound loop starts on the clients.
if !m.silent {
cx.events.push(MobEvent::EntityEvent {
mob: m.id,
event: 21,
at: [m.position.x, m.position.y, m.position.z],
});
}
} else if self.attack_time >= attack_duration(m) {
// Indirect magic (1, +2 on Hard, +2 for the elder), then
// doHurtTarget.
@@ -476,3 +476,12 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
p.yrot("tail1", tail.msin() * PI * 0.1);
p.yrot("tail2", tail.msin() * PI * 0.15);
}
/// `Guardian.getAttackAnimationScale(0)`: how far the beam has charged. A
/// beam that ended counts from zero again.
pub fn attack_scale(m: &Mob) -> f32 {
if m.extra.flag {
m.extra.counter2 as f32 / attack_duration(m) as f32
} else {
0.0
}
}
@@ -146,6 +146,15 @@ impl Goal for Spell {
}
}
/// `Entity.isInvisible` of an illusioner on a client: the shared flag stands
/// in for the effect the server holds, which draws the mirror images.
pub fn set_invisible(m: &mut Mob, on: bool) {
m.extra.effects.retain(|e| e.0 != INVISIBILITY);
if on {
m.extra.effects.push((INVISIBILITY, i32::MAX, 0));
}
}
/// The client-side half of `Illusioner.aiStep`: new image offsets when
/// hurt and once a minute, with a puff of cloud and a sound.
pub fn tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
+10 -3
View File
@@ -1118,17 +1118,24 @@ impl Mob {
self.ai.no_jump_delay = 10;
}
} else {
self.jump_in_liquid();
self.jump_in_liquid(true);
}
} else {
self.jump_in_liquid();
self.jump_in_liquid(false);
}
} else {
self.ai.no_jump_delay = 0;
}
}
/// Mob.jumpInLiquid: floaters rise 0.04 per tick, others 0.3; bees 0.01.
fn jump_in_liquid(&mut self) {
/// A magma cube in lava rises to `0.22 + size * 0.05` instead
/// (`MagmaCube.jumpInLiquid`).
fn jump_in_liquid(&mut self, lava: bool) {
if lava && self.species() == super::Species::MagmaCube {
self.velocity.y = f64::from(0.22_f32 + self.extra.size as f32 * 0.05);
self.needs_sync = true;
return;
}
self.velocity.y += if self.species() == super::Species::Bee {
0.01
} else if self.ai.nav.can_float {
+7
View File
@@ -465,6 +465,13 @@ pub fn celebrate(m: &mut Mob, w: &MobWorld) {
t.position.z.floor() as i32,
]);
}
/// `Hoglin.isConverting` and `AbstractPiglin.isConverting` on a client: in a
/// dimension that is not piglin safe (`nether` is false), a mob that is not
/// immune (`flag2`) and has AI shakes. `counter2 > 0` draws the shake, as it
/// does on the server while the zombification counts up.
pub fn client_converting(m: &mut Mob, nether: bool) {
m.extra.counter2 = i32::from(!nether && !m.extra.flag2 && !m.no_ai);
}
/// Per-tick piglin and hoglin state: admiring, anger, zombification and the
/// hoglin attack animation. Returns a conversion when it happens.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
+11
View File
@@ -923,6 +923,17 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4
(sign * horizon_core::jmath::acos(dot.abs().min(1.0))) as f32,
);
}
} else if m.species() == Species::Drowned {
// DrownedRenderer.setupRotations: tip toward the look pitch about
// the middle of the box while swimming.
let amount = m.extra.swim.amount(alpha);
if amount > 0.0 {
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
let middle = glam::Vec3::new(0.0, m.height() / 2.0, 0.0);
base *= glam::Mat4::from_translation(middle)
* glam::Mat4::from_rotation_x((amount * (-10.0 - pitch)).to_radians())
* glam::Mat4::from_translation(-middle);
}
} else if m.extra.swim.amount(alpha) > 0.0 {
// AvatarRenderer.setupRotations: tip forward with the swim amount,
// following the pitch in water, then lie along the swimming box.
+7 -2
View File
@@ -16,12 +16,13 @@
//! - run its client side animation in `client_tick`, sharing the function
//! with the server's `ai_step` where the server computes the same.
use super::{
Mob, Species, allay, armadillo, axolotl, breeze, camel, copper_golem, creaking, frog, goat,
golem, guardian, piglin, ravager, shulker, sniffer, warden,
Mob, Species, allay, armadillo, axolotl, breeze, camel, copper_golem, creaking, equine, frog,
goat, golem, guardian, piglin, ravager, shulker, sniffer, undead, villager, warden,
};
// `Pose.id` of the poses mobs take.
pub const STANDING: u8 = 0;
pub const SLEEPING: u8 = 2;
pub const LONG_JUMPING: u8 = 6;
pub const CROAKING: u8 = 8;
pub const USING_TONGUE: u8 = 9;
@@ -42,6 +43,8 @@ pub fn pose_id(m: &Mob) -> u8 {
Species::Breeze => breeze::pose_id(m),
Species::Camel | Species::CamelHusk if camel::sitting(m) => SITTING,
Species::Frog => m.extra.pose,
// LivingEntity.startSleeping: the pose, with the bed in index 14.
Species::Villager if villager::sleeping(m) => SLEEPING,
_ => STANDING,
}
}
@@ -87,6 +90,8 @@ pub fn client_tick(m: &mut Mob, game_time: i64) {
Species::Armadillo => armadillo::tick(m),
Species::Frog => frog::tick(m),
Species::Goat => goat::tick(m),
s if equine::grazes(s) => equine::client_tick(m),
Species::Drowned => undead::drowned_client_tick(m),
_ => {}
}
}
+213 -6
View File
@@ -12,7 +12,7 @@ use super::{
};
use horizon_core::geometry::trig::Mth;
use horizon_core::{
geometry::{Aabb, Vec3},
geometry::{Aabb, Axis, Vec3},
random::RandomSource,
};
use std::f32::consts::PI;
@@ -768,6 +768,7 @@ pub fn register_cat(m: &mut Mob) {
g.add(1, Float);
g.add(1, TamePanic(Panic::new(1.5)));
g.add(2, SitWhenOrdered);
g.add(3, CatRelaxOnOwner::default());
g.add(4, CatTempt(super::goals::Tempt::new(0.6, food, true)));
g.add(
4,
@@ -848,6 +849,202 @@ fn empty_above(w: &MobWorld, p: [i32; 3]) -> bool {
.as_ref()
== "minecraft:air"
}
/// Whether `p` is the player that owns `m` (a cat without a recorded owner
/// belongs to whoever it sees).
pub fn is_owner_of(m: &Mob, p: &super::PlayerView) -> bool {
m.extra.owner.is_none_or(|o| o == p.uuid)
}
/// `Cat.CatRelaxOnOwnerGoal`: while the owner sleeps, the cat lies down beside
/// them, and may leave a morning gift when they wake up.
#[derive(Clone, Debug, Default)]
struct CatRelaxOnOwner {
goal: Option<[i32; 3]>,
on_bed_ticks: i32,
}
impl CatRelaxOnOwner {
/// `spaceIsOccupied`: another cat lying or relaxing within two blocks.
fn occupied(&self, m: &Mob, w: &MobWorld) -> bool {
let Some(g) = self.goal else { return true };
let area = Aabb::new(
Vec3::new(
f64::from(g[0]) - 2.0,
f64::from(g[1]) - 2.0,
f64::from(g[2]) - 2.0,
),
Vec3::new(
f64::from(g[0]) + 3.0,
f64::from(g[1]) + 3.0,
f64::from(g[2]) + 3.0,
),
);
w.others.iter().any(|o| {
o.species == Species::Cat && o.id != m.id && o.lying && o.bounds.intersects(area)
})
}
/// The owner's sleeping state, when this cat may relax on them: the
/// player it sees has to be its owner (`Cat.getOwner`).
fn sleeping_owner<'w>(m: &Mob, w: &'w MobWorld) -> Option<&'w super::PlayerView> {
w.player
.as_ref()
.filter(|p| m.extra.tame && !m.extra.sitting && p.sleeping && is_owner_of(m, p))
}
}
impl Goal for CatRelaxOnOwner {
goal_clone!();
fn flags(&self) -> u8 {
0
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
let w = cx.w;
let Some(p) = Self::sleeping_owner(m, w) else {
return false;
};
if m.distance_sqr(p.position) > 100.0 {
return false;
}
let at = [
p.position.x.floor() as i32,
p.position.y.floor() as i32,
p.position.z.floor() as i32,
];
if !has_tag(w, at, "minecraft:cats_can_lie_on") {
return false;
}
// The bed's foot: against `BedBlock.FACING`.
let back = match w.prop(at[0], at[1], at[2], "facing") {
Some("north") => [0, 0, 1],
Some("south") => [0, 0, -1],
Some("east") => [-1, 0, 0],
Some("west") => [1, 0, 0],
Some("up") => [0, -1, 0],
Some("down") => [0, 1, 0],
_ => [0, 0, 0],
};
self.goal = Some([at[0] + back[0], at[1] + back[1], at[2] + back[2]]);
!self.occupied(m, w)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
Self::sleeping_owner(m, cx.w).is_some() && self.goal.is_some() && !self.occupied(m, cx.w)
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
if let Some(g) = self.goal {
m.extra.flag2 = false;
m.nav_move_to(
cx.w,
[f64::from(g[0]), f64::from(g[1]), f64::from(g[2])],
1.1,
);
}
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
m.extra.flag = false;
// The `gameplay/cat_waking_up_gift_chance` of the `day` timeline: 0.7
// from tick 23667 of the day to tick 362 of the next, 0 in between.
let day = cx.w.day_time.rem_euclid(24000);
let chance = if !cx.w.nether && !cx.w.end && !(362..23667).contains(&day) {
0.7
} else {
0.0
};
if cx
.w
.player
.as_ref()
.is_some_and(|p| is_owner_of(m, p) && p.sleep_timer >= 100)
&& m.random.next_float() < chance
{
give_morning_gift(m, cx);
}
self.on_bed_ticks = 0;
m.extra.relax_one = false;
m.nav_stop();
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
let (Some(p), Some(g)) = (cx.w.player.as_ref(), self.goal) else {
return;
};
m.extra.flag2 = false;
m.nav_move_to(
cx.w,
[f64::from(g[0]), f64::from(g[1]), f64::from(g[2])],
1.1,
);
if m.distance_sqr(p.position) < 2.5 {
self.on_bed_ticks += 1;
if self.on_bed_ticks > reduced(16) {
m.extra.flag = true;
m.extra.relax_one = false;
} else {
m.look_at_speed(p.eye, 45.0, 45.0);
m.extra.relax_one = true;
}
} else {
m.extra.flag = false;
}
}
}
/// `CatRelaxOnOwnerGoal.giveMorningGift`: hop to a spot within five blocks
/// (`randomTeleport`, avoiding `#dangerous_for_teleportation`) and drop a
/// `gameplay/cat_morning_gift` item at its feet.
fn give_morning_gift(m: &mut Mob, cx: &mut Cx) {
let w = cx.w;
let at = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
let x = at[0] + m.random.next_int_bound(11).unwrap_or(5) - 5;
let y = at[1] + m.random.next_int_bound(5).unwrap_or(2) - 2;
let z = at[2] + m.random.next_int_bound(11).unwrap_or(5) - 5;
let tagged = |p: [i32; 3], tag| has_tag(w, p, tag);
let mut ground = y - 1;
while ground > crate::chunk::MIN_Y
&& !tagged([x, ground, z], "minecraft:entities_can_teleport_to")
{
ground -= 1;
}
if w.world.loaded(x, z) && ground > crate::chunk::MIN_Y {
let feet = f64::from(ground + 1);
let half = f64::from(m.width()) / 2.0;
let b = Aabb::new(
Vec3::new(f64::from(x) - half, feet, f64::from(z) - half),
Vec3::new(
f64::from(x) + half,
feet + f64::from(m.height()),
f64::from(z) + half,
),
);
let dangerous = |p| tagged(p, "minecraft:dangerous_for_teleportation");
let in_box = |f: &dyn Fn([i32; 3]) -> bool| {
(b.min(Axis::X).floor() as i32..=b.max(Axis::X).floor() as i32).any(|bx| {
(b.min(Axis::Y).floor() as i32..=b.max(Axis::Y).floor() as i32).any(|by| {
(b.min(Axis::Z).floor() as i32..=b.max(Axis::Z).floor() as i32)
.any(|bz| f([bx, by, bz]))
})
})
};
let fluids = w.fluids(b, feet);
if !dangerous([x, ground, z])
&& w.no_collision(b)
&& !(fluids.in_water || fluids.in_lava)
&& !in_box(&dangerous)
{
m.position = Vec3::new(f64::from(x), feet, f64::from(z));
m.previous = m.position;
m.velocity = Vec3::ZERO;
}
}
let yaw = m.body_yaw * (PI / 180.0);
let here = [
m.position.x.floor() - f64::from(yaw.msin()),
m.position.y.floor(),
m.position.z.floor() + f64::from(yaw.mcos()),
];
cx.events.push(MobEvent::DropGift {
table: "minecraft:gameplay/cat_morning_gift",
at: here,
});
}
/// `CatLieOnBlockGoal`: tame cats lie on beds and other warm blocks.
#[derive(Clone, Debug)]
struct CatLieOnBlock(super::goals::MoveToBlock);
@@ -997,7 +1194,17 @@ impl Goal for OcelotAttack {
}
/// `Cat.tick`: lying and relaxing amounts and the purr.
pub fn cat_ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
cat_amounts(m);
if (m.extra.flag || m.extra.relax_one) && m.age.is_multiple_of(5) {
let volume = 0.6 + 0.4 * (m.random.next_float() - m.random.next_float());
events.push(MobEvent::Sound("entity.cat.purr", m.center(), volume, 1.0));
}
}
/// The lying and relaxing amounts of `Cat.tick` (also a client's, from the
/// synched flags).
pub fn cat_amounts(m: &mut Mob) {
let lying = m.extra.flag;
let relaxing = m.extra.relax_one;
let v = &mut m.anim.v;
if lying {
v[LIE] = (v[LIE] + 0.15).min(1.0);
@@ -1006,11 +1213,11 @@ pub fn cat_ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
v[LIE] = (v[LIE] - 0.22).max(0.0);
v[LIE_TAIL] = (v[LIE_TAIL] - 0.13).max(0.0);
}
v[RELAX] = (v[RELAX] - 0.13).max(0.0);
if lying && m.age.is_multiple_of(5) {
let volume = 0.6 + 0.4 * (m.random.next_float() - m.random.next_float());
events.push(MobEvent::Sound("entity.cat.purr", m.center(), volume, 1.0));
}
v[RELAX] = if relaxing {
(v[RELAX] + 0.1).min(1.0)
} else {
(v[RELAX] - 0.13).max(0.0)
};
}
/// `Cat.finalizeSpawn` variant: any but all-black, which a full moon allows.
pub fn cat_variant(m: &mut Mob, w_moon_full: bool) -> u8 {
+11
View File
@@ -181,6 +181,17 @@ fn drowned_ok_target(m: &Mob, w: &super::MobWorld) -> bool {
pub fn drowned_wants_to_swim(m: &Mob, w: &super::MobWorld) -> bool {
m.extra.flag2 || m.target_info(w).is_some_and(|t| in_water_at(w, t.position))
}
/// `Drowned.updateSwimming`: swimming under water while it wants to. `flag`
/// holds `isSwimming`, which clients take from the entity's shared flags.
pub fn drowned_update_swimming(m: &mut Mob, w: &super::MobWorld) {
m.extra.flag =
!m.no_ai && m.was_eye_in_water && m.fluids.in_water && drowned_wants_to_swim(m, w);
}
/// A drowned on a client: `LivingEntity.updateSwimAmount` follows the
/// swimming flag (`Drowned.isVisuallySwimming`).
pub fn drowned_client_tick(m: &mut Mob) {
m.extra.swim.tick(m.extra.flag, m.fluids.in_water);
}
/// Drowned.DrownedMoveControl
pub fn drowned_move_control(m: &mut Mob, w: &super::MobWorld) {
let target = m.target_info(w);
+28 -10
View File
@@ -170,9 +170,7 @@ fn target_pos(w: &MobWorld, id: i32) -> Option<([f64; 3], f64, bool)> {
}
}
/// `WitherBoss.aiStep` on a client of a server: the smoke about each head
/// (`radius` 0.3 of the wither's scale, which is 1 here). The heads' turning
/// and the coloured particles need the server's targets and invulnerable
/// ticks, which a client is not sent.
/// (`radius` 0.3 of the wither's scale, which is 1 here).
pub fn client_smoke(m: &mut Mob, events: &mut Vec<MobEvent>) {
for i in 0..3 {
let h = head_pos(m, i);
@@ -189,13 +187,12 @@ pub fn client_smoke(m: &mut Mob, events: &mut Vec<MobEvent>) {
});
}
}
/// `WitherBoss.aiStep` after `super.aiStep()`: the side heads turn, smoke
/// rises from the heads (the particle offsets are drawn on the server too).
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// Side heads turn toward their targets, else back to the body.
/// The side heads turn toward their targets, else back to the body. `find`
/// gives the position and eye height of the entity a head's target id names.
fn turn_heads(m: &mut Mob, find: impl Fn(i32) -> Option<([f64; 3], f64)>) {
for i in 0..2 {
match target_pos(w, m.extra.ints[i]) {
Some((p, eye, _)) => {
match find(m.extra.ints[i]) {
Some((p, eye)) => {
let h = head_pos(m, i + 1);
let (xd, yd, zd) = (p[0] - h[0], eye - h[1], p[2] - h[2]);
let sd = (xd * xd + zd * zd).sqrt();
@@ -207,6 +204,18 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
None => m.anim.v[i] = rotlerp(m.anim.v[i], m.body_yaw, 10.0),
}
}
}
/// `WitherBoss.aiStep` on a client that is not the server: the side heads
/// turn toward the targets of the synched data (`DATA_TARGET_B` and `_C`,
/// kept in `ints[0]` and `ints[1]` as network entity ids).
pub fn client_tick(m: &mut Mob, find: impl Fn(i32) -> Option<([f64; 3], f64)>) {
m.anim.vo = m.anim.v;
turn_heads(m, find);
}
/// `WitherBoss.aiStep` after `super.aiStep()`: the side heads turn, smoke
/// rises from the heads (the particle offsets are drawn on the server too).
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
turn_heads(m, |id| target_pos(w, id).map(|(p, eye, _)| (p, eye)));
let powered = powered(m);
for i in 0..3 {
let h = head_pos(m, i);
@@ -284,11 +293,20 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
let id = m.extra.ints[k];
if id != 0 {
match target_pos(w, id) {
// `hasLineOfSight` too: no volley through a wall.
Some((p, eye, _))
if (p[0] - m.position.x).powi(2)
+ (p[1] - m.position.y).powi(2)
+ (p[2] - m.position.z).powi(2)
<= 900.0 =>
<= 900.0
&& m.sees(
w,
if id == -1 {
Who::Player
} else {
Who::Mob(id as u32)
},
) =>
{
let at = [p[0], p[1] + (eye - p[1]) * 0.5, p[2]];
shoot(m, i + 1, at, false, events);
@@ -24,6 +24,14 @@ pub struct Cure {
pub fn cure(m: &Mob) -> Option<&Cure> {
villager::ext(m).and_then(|e| e.cure.as_ref())
}
/// `ZombieVillager.DATA_CONVERTING_ID` on a client: only `is_converting`
/// (the cure's shaking) reads the cure, so its time does not matter.
pub fn set_converting(m: &mut Mob, on: bool) {
villager::ext_mut(m).cure = on.then_some(Cure {
time: 0,
starter: None,
});
}
pub fn is_converting(m: &Mob) -> bool {
m.species() == Species::ZombieVillager && cure(m).is_some()
}
+2 -25
View File
@@ -673,37 +673,14 @@ const NBT_TABLES: [&str; 6] = [
"worldgen/blocks/cached-support.nbt",
"worldgen/blocks/cached-collision.nbt",
];
/// In a root built here: the namespaces the content mods own (their ids and
/// listed namespaces), which loaders read besides `minecraft`.
const MARKER: &str = "horizon-mod-content.json";
use horizon_items::content_namespaces::MARKER;
pub use horizon_items::content_namespaces::{namespaces, tag_namespaces};
/// Block states a world can hold. Chunk sections widen their global palette
/// past vanilla's 16 bits to fit the registry (crate::chunk::set_block_states);
/// this caps it at 20 bits, about 29 times vanilla's 35,723 states. Mods past
/// it are refused, not wrapped onto other blocks.
pub const MAX_STATES: u32 = 1 << 20;
/// The content mods' namespaces in a root built by [`build`]; none in any
/// other root, so vanilla's loaders read exactly what they did.
pub fn namespaces(root: &Path) -> Vec<String> {
fs::read(root.join(MARKER))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
/// The namespaces whose tags loaders read in `root`: `minecraft`, and in a
/// root built by [`build`] the convention tags (`c`, shared between mods as
/// on NeoForge and Fabric) and the content mods' own.
pub fn tag_namespaces(root: &Path) -> Vec<String> {
let mods = namespaces(root);
let mut out = vec!["minecraft".to_owned()];
if !mods.is_empty() {
out.push("c".to_owned());
out.extend(mods);
}
out
}
/// Bundles of block tag files (tag id → the tag file's text).
const TAG_BUNDLES: [&str; 2] = ["block-tags.json", "worldgen/block_tags.json"];
/// The worldgen loader's bundle of biome tags.
+33 -89
View File
@@ -24,6 +24,7 @@ use std::{
};
pub use horizon_client_core::particle_type::ParticleType;
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
/// `ParticleOptions` with the parameters of the option classes used here.
#[derive(Clone, Debug)]
@@ -3838,6 +3839,38 @@ fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
}
struct Starter {
at: [f64; 3],
v: [f64; 3],
explosions: Vec<FireworkExplosion>,
life: i32,
lifetime: i32,
twinkle_delay: bool,
far: bool,
}
const STAR: [[f64; 2]; 6] = [
[0.0, 1.0],
[0.3455, 0.309],
[0.9511, 0.309],
[0.379_591_836_734_693_9, -0.126_530_612_244_897_95],
[0.612_244_897_959_183_7, -0.804_081_632_653_061_2],
[0.0, -0.359_183_673_469_387_73],
];
const CREEPER: [[f64; 2]; 12] = [
[0.0, 0.2],
[0.2, 0.2],
[0.2, 0.6],
[0.6, 0.6],
[0.6, 0.2],
[0.2, 0.2],
[0.2, 0.0],
[0.4, 0.0],
[0.4, -0.6],
[0.2, -0.6],
[0.2, -0.4],
[0.0, -0.4],
];
#[cfg(test)]
mod tests {
use super::*;
@@ -4025,92 +4058,3 @@ mod tests {
assert!((lifetime_alpha(a, 40, 40, 0.0) - 0.6).abs() < 1e-6);
}
}
/// FireworkExplosion.Shape.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FireworkShape {
#[default]
SmallBall,
LargeBall,
Star,
Creeper,
Burst,
}
impl FireworkShape {
pub fn from_name(name: &str) -> Self {
match name {
"large_ball" => Self::LargeBall,
"star" => Self::Star,
"creeper" => Self::Creeper,
"burst" => Self::Burst,
_ => Self::SmallBall,
}
}
pub fn name(self) -> &'static str {
match self {
Self::SmallBall => "small_ball",
Self::LargeBall => "large_ball",
Self::Star => "star",
Self::Creeper => "creeper",
Self::Burst => "burst",
}
}
}
/// `DyeColor` names and `getFireworkColor`, by dye id (white to black).
pub const DYE_FIREWORK_COLORS: [(&str, u32); 16] = [
("white", 0xF0F0F0),
("orange", 0xEB8844),
("magenta", 0xC354CD),
("light_blue", 0x6689D3),
("yellow", 0xDECF2A),
("lime", 0x41CD34),
("pink", 0xD88198),
("gray", 0x434343),
("light_gray", 0xABABAB),
("cyan", 0x287697),
("purple", 0x7B2FBE),
("blue", 0x253192),
("brown", 0x51301A),
("green", 0x3B511A),
("red", 0xB3312C),
("black", 0x1E1B1B),
];
/// The `firework_explosion` component.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FireworkExplosion {
pub shape: FireworkShape,
pub colors: Vec<u32>,
pub fade: Vec<u32>,
pub trail: bool,
pub twinkle: bool,
}
struct Starter {
at: [f64; 3],
v: [f64; 3],
explosions: Vec<FireworkExplosion>,
life: i32,
lifetime: i32,
twinkle_delay: bool,
far: bool,
}
const STAR: [[f64; 2]; 6] = [
[0.0, 1.0],
[0.3455, 0.309],
[0.9511, 0.309],
[0.379_591_836_734_693_9, -0.126_530_612_244_897_95],
[0.612_244_897_959_183_7, -0.804_081_632_653_061_2],
[0.0, -0.359_183_673_469_387_73],
];
const CREEPER: [[f64; 2]; 12] = [
[0.0, 0.2],
[0.2, 0.2],
[0.2, 0.6],
[0.6, 0.6],
[0.6, 0.2],
[0.2, 0.2],
[0.2, 0.0],
[0.4, 0.0],
[0.4, -0.6],
[0.2, -0.6],
[0.2, -0.4],
[0.0, -0.4],
];
+5 -77
View File
@@ -104,27 +104,8 @@ pub fn chunks_with_block_entities<'a>(
.collect()
}
fn string(s: &str) -> Tag {
Tag::String(s.into())
}
pub fn text(tag: Option<&Tag>) -> Option<String> {
match tag {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
}
}
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
pub fn number(tag: Option<&Tag>) -> Option<i64> {
Some(match tag? {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
Tag::Float(b) => f32::from_bits(*b) as i64,
Tag::Double(b) => f64::from_bits(*b) as i64,
_ => return None,
})
}
use horizon_client_core::nbt_value::string;
pub use horizon_client_core::nbt_value::{number, text};
pub(crate) fn float(tag: Option<&Tag>) -> Option<f64> {
Some(match tag? {
Tag::Float(b) => f64::from(f32::from_bits(*b)),
@@ -412,62 +393,9 @@ fn fluid_id(name: &str) -> Option<u32> {
} as u32)
}
fn encode_item(stack: &crate::items::ItemStack) -> Vec<(&'static str, Tag)> {
let mut entries = vec![
("id", string(&stack.item)),
("count", Tag::Int(stack.count as i32)),
];
if let Some(components) = crate::item_data::encode_components(stack) {
entries.push(("components", components));
}
entries
}
/// ItemStack.CODEC (id, count, components).
pub fn item_to_nbt(stack: &crate::items::ItemStack) -> Tag {
Tag::compound(encode_item(stack))
}
pub fn item_from_nbt(tag: &Tag) -> Option<crate::items::ItemStack> {
let id = text(tag.get("id"))?;
if id == "minecraft:air" {
return None;
}
let count = number(tag.get("count"))
.or_else(|| number(tag.get("Count")))
.unwrap_or(1)
.max(1) as u32;
let (damage, extra) = crate::item_data::decode_components(tag.get("components"));
Some(crate::items::ItemStack {
item: Arc::from(id.as_str()),
count,
damage,
extra,
})
}
pub fn encode_items(slots: &[Option<crate::items::ItemStack>]) -> Tag {
Tag::List(
slots
.iter()
.enumerate()
.filter_map(|(i, s)| {
let mut entries = vec![("Slot", Tag::Byte(i as i8))];
entries.extend(encode_item(s.as_ref()?));
Some(Tag::compound(entries))
})
.collect(),
)
}
pub fn decode_items(tag: Option<&Tag>, size: usize) -> Vec<Option<crate::items::ItemStack>> {
let mut slots = vec![None; size];
if let Some(Tag::List(items)) = tag {
for it in items {
let slot = number(it.get("Slot")).unwrap_or(0) as u8 as usize;
if let Some(cell) = slots.get_mut(slot) {
*cell = item_from_nbt(it);
}
}
}
slots
}
pub use horizon_items::item_nbt::{
decode_items, encode_item, encode_items, item_from_nbt, item_to_nbt,
};
/// The block entity type ID for the block it sits in (a chest-like container
/// is a chest, trapped chest or barrel).
fn block_entity_id(e: &crate::containers::BlockEntity, block: &str) -> String {
+123
View File
@@ -53,6 +53,11 @@ pub enum Ticking {
Minecart { cart: u32 },
/// `RidingMinecartSoundInstance` for the player riding a minecart.
RidingMinecart { cart: u32, underwater: bool },
/// `BeeFlyingSoundInstance`, or `BeeAggressiveSoundInstance` when
/// `aggressive`.
Bee { bee: u32, aggressive: bool },
/// `GuardianAttackSoundInstance`.
GuardianAttack { guardian: u32 },
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -62,6 +67,21 @@ pub struct CartSound {
/// `getDeltaMovement().horizontalDistance()`.
pub speed: f64,
}
/// What a bee's or a guardian's sound instance reads about it.
#[derive(Clone, Copy, Debug)]
pub struct MobSound {
pub id: u32,
pub position: [f64; 3],
/// `getDeltaMovement().horizontalDistance()`.
pub speed: f64,
pub baby: bool,
/// `Bee.isAngry`.
pub angry: bool,
/// `isSilent`.
pub silent: bool,
/// `Guardian.getAttackAnimationScale(0)`.
pub attack_scale: f32,
}
/// What tickable instances read about the player and the entities they follow.
#[derive(Clone, Debug, Default)]
pub struct TickContext {
@@ -71,6 +91,8 @@ pub struct TickContext {
pub vehicle: Option<u32>,
/// The minecarts in the level (a missing one has been removed).
pub carts: Vec<CartSound>,
/// The bees and guardians in the level (a missing one has been removed).
pub mobs: Vec<MobSound>,
/// `!tickRateManager().runsNormally()`.
pub frozen: bool,
}
@@ -315,6 +337,59 @@ impl SoundInstance {
};
s
}
/// `new BeeFlyingSoundInstance(bee)` (or `BeeAggressiveSoundInstance`):
/// silent until the bee moves.
pub fn bee(bee: &MobSound, aggressive: bool, random: LegacyRandom) -> Self {
let id = if aggressive {
"minecraft:entity.bee.loop_aggressive"
} else {
"minecraft:entity.bee.loop"
};
let [x, y, z] = bee.position;
let mut s = Self::new(
id,
NEUTRAL,
0.0,
1.0,
random,
true,
0,
Attenuation::Linear,
[
f64::from(x as f32),
f64::from(y as f32),
f64::from(z as f32),
],
false,
);
s.can_start_silent = true;
s.ticking = Ticking::Bee {
bee: bee.id,
aggressive,
};
s
}
/// `new GuardianAttackSoundInstance(guardian)`: the guardian's own volume
/// and pitch until its first tick, heard everywhere.
pub fn guardian_attack(guardian: &MobSound, random: LegacyRandom) -> Self {
let id = "minecraft:entity.guardian.attack";
let mut s = Self::new(
id,
crate::settings::sound_source(id),
1.0,
1.0,
random,
true,
0,
Attenuation::None,
guardian.position,
false,
);
s.ticking = Ticking::GuardianAttack {
guardian: guardian.id,
};
s
}
pub fn tickable(&self) -> bool {
self.ticking != Ticking::No
}
@@ -389,6 +464,54 @@ impl SoundInstance {
0.0
};
}
Ticking::Bee { bee, aggressive } => {
let (id, aggressive) = (*bee, *aggressive);
// canPlaySound, then shouldSwitchSounds: the other kind of
// instance is started in this one's place.
let Some(bee) = ctx
.mobs
.iter()
.find(|m| m.id == id)
.filter(|m| !m.silent && m.angry == aggressive)
else {
self.stop();
return;
};
let [x, y, z] = bee.position;
self.position = [
f64::from(x as f32),
f64::from(y as f32),
f64::from(z as f32),
];
let (min, max) = if bee.baby { (1.1, 1.5) } else { (0.7, 1.1) };
let speed = bee.speed as f32;
if speed >= 0.01 {
// Mth.lerp(Mth.clamp(speed, min, max), min, max).
self.pitch = min + speed.clamp(min, max) * (max - min);
self.volume = speed.clamp(0.0, 0.5) * 1.2;
} else {
self.pitch = 0.0;
self.volume = 0.0;
}
}
Ticking::GuardianAttack { guardian } => {
let id = *guardian;
// The client's guardian never has a target of its own, so
// only its removal or silence ends the loop.
let Some(guardian) = ctx.mobs.iter().find(|m| m.id == id && !m.silent) else {
self.stop();
return;
};
let [x, y, z] = guardian.position;
self.position = [
f64::from(x as f32),
f64::from(y as f32),
f64::from(z as f32),
];
let scale = guardian.attack_scale;
self.volume = 0.0 + 1.0 * scale * scale;
self.pitch = 0.7 + 0.5 * scale;
}
Ticking::RidingMinecart { cart, underwater } => {
let (id, underwater) = (*cart, *underwater);
let found = ctx.carts.iter().find(|c| c.id == id);
+31 -10
View File
@@ -2,6 +2,7 @@
//! conditions of a stab, `Item.Properties.spear`'s numbers) and
//! `SpearAnimations` (how the spear is held in first and third person while
//! it charges).
use crate::first_person::SwingAnimation;
use glam::{Mat4, Vec3};
use horizon_core::{geometry::trig::Mth, jmath::pow};
use std::f32::consts::PI;
@@ -39,6 +40,10 @@ pub struct Kinetic {
pub damage_multiplier: f32,
pub sound: &'static str,
pub hit_sound: &'static str,
/// The `attack_animation` duration, and the `piercing_weapon` sound of a
/// jab.
pub attack_ticks: i32,
pub attack_sound: &'static str,
}
/// `AttackRange` of a spear: the reach of its stabs.
@@ -62,16 +67,17 @@ pub const REACH: Reach = Reach {
impl Kinetic {
/// The `kinetic_weapon` of a spear item (`Item.Properties.spear`).
pub fn of(item: &str) -> Option<Self> {
// (damage multiplier, delay, dismount time and speed, knockback time
// and speed, damage time and speed): seconds and blocks per second.
let (multiplier, delay, dismount, knockback, damage) = match item {
"minecraft:wooden_spear" => (0.7, 0.75, (5.0, 14.0), (10.0, 5.1), (15.0, 4.6)),
"minecraft:stone_spear" => (0.82, 0.7, (4.5, 13.0), (9.0, 5.1), (13.75, 4.6)),
"minecraft:copper_spear" => (0.82, 0.65, (4.0, 12.0), (8.25, 5.1), (12.5, 4.6)),
"minecraft:iron_spear" => (0.95, 0.6, (2.5, 11.0), (6.75, 5.1), (11.25, 4.6)),
"minecraft:golden_spear" => (0.7, 0.7, (3.5, 13.0), (8.5, 5.1), (13.75, 4.6)),
"minecraft:diamond_spear" => (1.075, 0.5, (3.0, 10.0), (6.5, 5.1), (10.0, 4.6)),
"minecraft:netherite_spear" => (1.2, 0.4, (2.5, 9.0), (5.5, 5.1), (8.75, 4.6)),
// (attack duration, damage multiplier, delay, dismount time and
// speed, knockback time and speed, damage time and speed): seconds
// and blocks per second.
let (attack, multiplier, delay, dismount, knockback, damage) = match item {
"minecraft:wooden_spear" => (0.65, 0.7, 0.75, (5.0, 14.0), (10.0, 5.1), (15.0, 4.6)),
"minecraft:stone_spear" => (0.75, 0.82, 0.7, (4.5, 13.0), (9.0, 5.1), (13.75, 4.6)),
"minecraft:copper_spear" => (0.85, 0.82, 0.65, (4.0, 12.0), (8.25, 5.1), (12.5, 4.6)),
"minecraft:iron_spear" => (0.95, 0.95, 0.6, (2.5, 11.0), (6.75, 5.1), (11.25, 4.6)),
"minecraft:golden_spear" => (0.95, 0.7, 0.7, (3.5, 13.0), (8.5, 5.1), (13.75, 4.6)),
"minecraft:diamond_spear" => (1.05, 1.075, 0.5, (3.0, 10.0), (6.5, 5.1), (10.0, 4.6)),
"minecraft:netherite_spear" => (1.15, 1.2, 0.4, (2.5, 9.0), (5.5, 5.1), (8.75, 4.6)),
_ => return None,
};
let wood = item == "minecraft:wooden_spear";
@@ -107,8 +113,23 @@ impl Kinetic {
} else {
"minecraft:item.spear.hit"
},
attack_ticks: ticks(attack),
attack_sound: if wood {
"minecraft:item.spear_wood.attack"
} else {
"minecraft:item.spear.attack"
},
})
}
/// The spear's `attack_animation`: a STAB (`SwingAnimationType` 2) as long
/// as its attack duration.
pub fn attack_animation(&self) -> SwingAnimation {
SwingAnimation {
kind: 2,
duration: self.attack_ticks,
}
}
}
/// `KineticWeapon.HIT_FEEDBACK_TICKS`.
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "horizon-items"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "Blocks and items as the client knows them: block facts, item stacks and their data components, enchantments, cooldowns, bundles, and items in NBT"
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
horizon-core = { path = "../horizon-core" }
horizon-world = { path = "../horizon-world" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
[features]
# Test helpers (`Items::for_tests`, `enchant::tests`) for horizon-client's unit tests.
test-support = []
[lints]
workspace = true
+56
View File
@@ -0,0 +1,56 @@
//! Banner pattern layers (`BannerPatternLayers`) and their NBT form.
use horizon_world::nbt::Tag;
use std::sync::Arc;
/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BannerLayer {
pub pattern: Arc<str>,
pub color: Arc<str>,
}
impl BannerLayer {
pub fn new(pattern: &str, color: &str) -> Self {
Self {
pattern: Arc::from(pattern),
color: Arc::from(color),
}
}
}
/// The `banner_patterns` NBT list of layers.
pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag {
Tag::List(
layers
.iter()
.map(|l| {
Tag::compound([
("pattern", Tag::String((&*l.pattern).into())),
("color", Tag::String((&*l.color).into())),
])
})
.collect(),
)
}
pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
let Some(Tag::List(list)) = tag else {
return Vec::new();
};
list.iter()
.filter_map(|e| {
let text = |k: &str| match e.get(k) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
let pattern = text("pattern")?;
let color = text("color")?;
Some(BannerLayer::new(
&if pattern.contains(':') {
pattern
} else {
format!("minecraft:{pattern}")
},
&color,
))
})
.collect()
}
@@ -375,14 +375,7 @@ mod tooltip_tests {
/// The prepared resources, when they are on this machine: the tooltip
/// asks the item registry whether a stack is a bundle.
fn items() -> Option<Items> {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return None;
}
let assets = crate::assets::Assets::load(&root).ok()?;
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?;
Items::load(&root, assets.registry, &facts).ok()
crate::items::prepared_items()
}
fn bundle_with(counts: &[(&str, u32)]) -> ItemStack {
@@ -0,0 +1,29 @@
//! The namespaces content mods add to a resource root, which loaders read
//! besides `minecraft`.
use std::{fs, path::Path};
/// In a root built here: the namespaces the content mods own (their ids and
/// listed namespaces), which loaders read besides `minecraft`.
pub const MARKER: &str = "horizon-mod-content.json";
/// The content mods' namespaces in a root built by `mod_content::build`; none in any
/// other root, so vanilla's loaders read exactly what they did.
pub fn namespaces(root: &Path) -> Vec<String> {
fs::read(root.join(MARKER))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
/// The namespaces whose tags loaders read in `root`: `minecraft`, and in a
/// root built by `mod_content::build` the convention tags (`c`, shared between mods as
/// on NeoForge and Fabric) and the content mods' own.
pub fn tag_namespaces(root: &Path) -> Vec<String> {
let mods = namespaces(root);
let mut out = vec!["minecraft".to_owned()];
if !mods.is_empty() {
out.push("c".to_owned());
out.extend(mods);
}
out
}
@@ -334,14 +334,14 @@ impl Enchantments {
}
/// `Enchantment.getFullname` as plain text and whether it is a curse (red).
pub fn full_name(&self, id: &str, level: u32) -> (String, bool) {
let language = crate::lang::current();
let language = horizon_client_core::lang::current();
self.full_name_in(language.as_deref(), id, level)
}
/// `full_name` against `language`; `None` (no language loaded: a
/// dedicated server, the probes) keeps the English names loaded above.
pub fn full_name_in(
&self,
language: Option<&crate::lang::Language>,
language: Option<&horizon_client_core::lang::Language>,
id: &str,
level: u32,
) -> (String, bool) {
@@ -802,8 +802,8 @@ pub fn apply_value_effect(
}
}
#[cfg(test)]
pub(crate) mod tests {
#[cfg(any(test, feature = "test-support"))]
pub mod tests {
use super::*;
use serde_json::json;
@@ -1136,7 +1136,7 @@ pub(crate) mod tests {
#[test]
fn names_follow_the_language() {
struct Mem(HashMap<String, Vec<u8>>);
impl crate::lang::ResourceSource for Mem {
impl horizon_client_core::lang::ResourceSource for Mem {
fn read(&self, path: &str) -> Option<Vec<u8>> {
self.0.get(path).cloned()
}
@@ -1147,7 +1147,7 @@ pub(crate) mod tests {
.as_bytes()
.to_vec(),
)]));
let ru = crate::lang::Language::load(&[&ru], "ru_ru");
let ru = horizon_client_core::lang::Language::load(&[&ru], "ru_ru");
let e = sample();
assert_eq!(
e.full_name_in(Some(&ru), "minecraft:sharpness", 5).0,
+61
View File
@@ -0,0 +1,61 @@
//! Firework explosions (the `firework_explosion` component and its shapes) and
//! the dye colours fireworks use.
/// FireworkExplosion.Shape.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FireworkShape {
#[default]
SmallBall,
LargeBall,
Star,
Creeper,
Burst,
}
impl FireworkShape {
pub fn from_name(name: &str) -> Self {
match name {
"large_ball" => Self::LargeBall,
"star" => Self::Star,
"creeper" => Self::Creeper,
"burst" => Self::Burst,
_ => Self::SmallBall,
}
}
pub fn name(self) -> &'static str {
match self {
Self::SmallBall => "small_ball",
Self::LargeBall => "large_ball",
Self::Star => "star",
Self::Creeper => "creeper",
Self::Burst => "burst",
}
}
}
/// `DyeColor` names and `getFireworkColor`, by dye id (white to black).
pub const DYE_FIREWORK_COLORS: [(&str, u32); 16] = [
("white", 0xF0F0F0),
("orange", 0xEB8844),
("magenta", 0xC354CD),
("light_blue", 0x6689D3),
("yellow", 0xDECF2A),
("lime", 0x41CD34),
("pink", 0xD88198),
("gray", 0x434343),
("light_gray", 0xABABAB),
("cyan", 0x287697),
("purple", 0x7B2FBE),
("blue", 0x253192),
("brown", 0x51301A),
("green", 0x3B511A),
("red", 0xB3312C),
("black", 0x1E1B1B),
];
/// The `firework_explosion` component.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FireworkExplosion {
pub shape: FireworkShape,
pub colors: Vec<u32>,
pub fade: Vec<u32>,
pub trail: bool,
pub twinkle: bool,
}
@@ -28,7 +28,7 @@ pub struct StackData {
pub charged: Vec<ItemStack>,
/// `minecraft:fireworks`: flight duration and explosions (None: the
/// item's default).
pub fireworks: Option<(u8, Vec<crate::particles::FireworkExplosion>)>,
pub fireworks: Option<(u8, Vec<crate::firework::FireworkExplosion>)>,
/// `minecraft:map_id` (filled maps).
pub map_id: Option<i32>,
/// `minecraft:writable_book_content` pages.
@@ -36,7 +36,7 @@ pub struct StackData {
/// `minecraft:written_book_content`.
pub written: Option<WrittenBook>,
/// `minecraft:banner_patterns` (banners and shields).
pub banner_patterns: Vec<crate::banner::BannerLayer>,
pub banner_patterns: Vec<crate::banner_layer::BannerLayer>,
/// `minecraft:base_color` (a shield's dye).
pub base_color: Option<Arc<str>>,
/// The raid captain's banner: `minecraft:banner_patterns` with the
@@ -47,7 +47,7 @@ pub struct StackData {
/// `minecraft:dyed_color` (leather armour, wolf armour, ... as RGB).
pub dyed_color: Option<i32>,
/// `minecraft:firework_explosion` (a firework star).
pub firework_explosion: Option<crate::particles::FireworkExplosion>,
pub firework_explosion: Option<crate::firework::FireworkExplosion>,
/// `minecraft:pot_decorations`: back, left, right, front sherds.
pub pot_decorations: Option<Box<[Option<ItemStack>; 4]>>,
/// `minecraft:bundle_contents`.
@@ -109,7 +109,7 @@ impl StackData {
/// `SetNameFunction.run`: a loot text component stored as `custom_name`
/// (plain text, as modelled) or as the unmodelled `item_name`.
pub fn set_loot_name(&mut self, id: &str, text: &serde_json::Value) {
let tag = crate::scoreboard::json_to_tag(text);
let tag = horizon_client_core::scoreboard::json_to_tag(text);
if id == "minecraft:custom_name" {
self.custom_name = read_text(&tag);
} else {
@@ -252,7 +252,7 @@ impl ItemStack {
/// by `ominous_banner` instead.
pub fn item_name(&self) -> Option<String> {
let name = self.data()?.other("minecraft:item_name")?;
Some(crate::entity_nbt::name_text(name).plain())
Some(horizon_client_core::component::name_text(name).plain())
}
pub fn potion(&self) -> Option<&str> {
self.data().and_then(|d| d.potion.as_deref())
@@ -364,13 +364,15 @@ fn read_text(t: &Tag) -> Option<String> {
// this), so flatten the whole component instead of reading one key.
// ComponentSerialization.CODEC also takes a list (its first element
// styling the rest), as servers behind ViaVersion send book pages.
Tag::Compound(_) | Tag::List(_) => Some(crate::entity_nbt::name_text(t).plain()),
Tag::Compound(_) | Tag::List(_) => {
Some(horizon_client_core::component::name_text(t).plain())
}
_ => None,
}
}
/// A `FireworkExplosion` as vanilla's codec writes it (defaults omitted).
fn explosion_tag(e: &crate::particles::FireworkExplosion) -> Tag {
fn explosion_tag(e: &crate::firework::FireworkExplosion) -> Tag {
let colors = |c: &[u32]| Tag::List(c.iter().map(|c| Tag::Int(*c as i32)).collect());
let mut fields = vec![("shape", Tag::String(e.shape.name().into()))];
if !e.colors.is_empty() {
@@ -387,14 +389,14 @@ fn explosion_tag(e: &crate::particles::FireworkExplosion) -> Tag {
}
Tag::compound(fields)
}
fn explosion_from(e: &Tag) -> crate::particles::FireworkExplosion {
fn explosion_from(e: &Tag) -> crate::firework::FireworkExplosion {
let ints = |t: Option<&Tag>| match t {
Some(Tag::IntArray(v)) => v.iter().map(|c| *c as u32).collect(),
Some(Tag::List(v)) => v.iter().filter_map(int).map(|c| c as u32).collect(),
_ => Vec::new(),
};
crate::particles::FireworkExplosion {
shape: crate::particles::FireworkShape::from_name(
crate::firework::FireworkExplosion {
shape: crate::firework::FireworkShape::from_name(
e.get("shape")
.and_then(string)
.as_deref()
@@ -442,7 +444,7 @@ fn template_from(tag: &Tag) -> Option<ItemStack> {
/// The stack a `show_item` hover event holds (`HoverEvent.ShowItem`): its
/// `{id, count?, components?}` object.
pub fn stack_from_hover(event: &serde_json::Value) -> Option<ItemStack> {
template_from(&crate::scoreboard::json_to_tag(event))
template_from(&horizon_client_core::scoreboard::json_to_tag(event))
}
/// Sorted-key, type-annotated text of a tag (the differential harnesses
/// print the same form from the vanilla side).
@@ -508,7 +510,7 @@ pub fn canon_tag(tag: &Tag) -> String {
pub fn canon_stack(stack: Option<&ItemStack>) -> String {
stack.map_or_else(
|| "-".to_owned(),
|s| canon_tag(&crate::save::item_to_nbt(s)),
|s| canon_tag(&crate::item_nbt::item_to_nbt(s)),
)
}
/// The stack of an SNBT `ItemStack.CODEC` compound; None for "-".
@@ -517,7 +519,7 @@ pub fn parse_stack(snbt: &str) -> Result<Option<ItemStack>, String> {
return Ok(None);
}
let (tag, _) = horizon_world::nbt::parse_snbt(snbt)?;
Ok(crate::save::item_from_nbt(&tag))
Ok(crate::item_nbt::item_from_nbt(&tag))
}
/// The `components` compound for a stack, or None when every component is default.
pub fn encode_components(stack: &ItemStack) -> Option<Tag> {
@@ -566,7 +568,7 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
if !d.banner_patterns.is_empty() {
entries.push((
"minecraft:banner_patterns",
crate::banner::layers_to_nbt(&d.banner_patterns),
crate::banner_layer::layers_to_nbt(&d.banner_patterns),
));
}
if let Some(color) = &d.base_color {
@@ -669,7 +671,7 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option<Tag> {
if !d.charged.is_empty() {
entries.push((
"minecraft:charged_projectiles",
Tag::List(d.charged.iter().map(crate::save::item_to_nbt).collect()),
Tag::List(d.charged.iter().map(crate::item_nbt::item_to_nbt).collect()),
));
}
if d.ominous_banner {
@@ -752,7 +754,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
custom_name: c.get("minecraft:custom_name").and_then(read_text),
potion,
map_id: c.get("minecraft:map_id").and_then(int).map(|v| v as i32),
banner_patterns: crate::banner::layers_from_nbt(c.get("minecraft:banner_patterns")),
banner_patterns: crate::banner_layer::layers_from_nbt(c.get("minecraft:banner_patterns")),
base_color: c
.get("minecraft:base_color")
.and_then(string)
@@ -831,7 +833,10 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
None => None,
},
charged: match c.get("minecraft:charged_projectiles") {
Some(Tag::List(list)) => list.iter().filter_map(crate::save::item_from_nbt).collect(),
Some(Tag::List(list)) => list
.iter()
.filter_map(crate::item_nbt::item_from_nbt)
.collect(),
_ => Vec::new(),
},
container: match c.get("minecraft:container") {
@@ -1010,8 +1015,8 @@ mod tests {
let mut stack = ItemStack::new("minecraft:shield", 1);
stack.data_mut().base_color = Some(Arc::from("red"));
stack.data_mut().banner_patterns = vec![
crate::banner::BannerLayer::new("minecraft:flower", "black"),
crate::banner::BannerLayer::new("minecraft:border", "white"),
crate::banner_layer::BannerLayer::new("minecraft:flower", "black"),
crate::banner_layer::BannerLayer::new("minecraft:border", "white"),
];
let tag = encode_components(&stack).expect("components");
let (_, extra) = decode_components(Some(&tag));
@@ -1082,7 +1087,7 @@ mod tests {
assert_eq!(flight, 2);
assert_eq!(
explosions[0].shape,
crate::particles::FireworkShape::LargeBall
crate::firework::FireworkShape::LargeBall
);
assert_eq!(explosions[0].colors, vec![0xFF0000]);
assert!(explosions[0].twinkle && !explosions[0].trail);
+62
View File
@@ -0,0 +1,62 @@
//! Item stacks in NBT (`ItemStack.CODEC`): id, count and components, alone or in
//! slot lists.
use horizon_client_core::nbt_value::{number, string, text};
use horizon_world::nbt::Tag;
use std::sync::Arc;
pub fn encode_item(stack: &crate::items::ItemStack) -> Vec<(&'static str, Tag)> {
let mut entries = vec![
("id", string(&stack.item)),
("count", Tag::Int(stack.count as i32)),
];
if let Some(components) = crate::item_data::encode_components(stack) {
entries.push(("components", components));
}
entries
}
/// ItemStack.CODEC (id, count, components).
pub fn item_to_nbt(stack: &crate::items::ItemStack) -> Tag {
Tag::compound(encode_item(stack))
}
pub fn item_from_nbt(tag: &Tag) -> Option<crate::items::ItemStack> {
let id = text(tag.get("id"))?;
if id == "minecraft:air" {
return None;
}
let count = number(tag.get("count"))
.or_else(|| number(tag.get("Count")))
.unwrap_or(1)
.max(1) as u32;
let (damage, extra) = crate::item_data::decode_components(tag.get("components"));
Some(crate::items::ItemStack {
item: Arc::from(id.as_str()),
count,
damage,
extra,
})
}
pub fn encode_items(slots: &[Option<crate::items::ItemStack>]) -> Tag {
Tag::List(
slots
.iter()
.enumerate()
.filter_map(|(i, s)| {
let mut entries = vec![("Slot", Tag::Byte(i as i8))];
entries.extend(encode_item(s.as_ref()?));
Some(Tag::compound(entries))
})
.collect(),
)
}
pub fn decode_items(tag: Option<&Tag>, size: usize) -> Vec<Option<crate::items::ItemStack>> {
let mut slots = vec![None; size];
if let Some(Tag::List(items)) = tag {
for it in items {
let slot = number(it.get("Slot")).unwrap_or(0) as u8 as usize;
if let Some(cell) = slots.get_mut(slot) {
*cell = item_from_nbt(it);
}
}
}
slots
}
@@ -310,7 +310,7 @@ fn load_songs(root: &Path) -> HashMap<String, Song> {
}
songs
}
pub(crate) fn load_tags(root: &Path, kind: &str) -> Result<HashMap<String, HashSet<String>>> {
pub fn load_tags(root: &Path, kind: &str) -> Result<HashMap<String, HashSet<String>>> {
Ok(load_tag_lists(root, kind)?
.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
@@ -350,7 +350,7 @@ pub(crate) fn load_tag_lists(root: &Path, kind: &str) -> Result<HashMap<String,
Ok(())
}
// Vanilla's tags; with content mods also the convention (`c`) tags and the mods' own.
for namespace in crate::mod_content::tag_namespaces(root) {
for namespace in crate::content_namespaces::tag_namespaces(root) {
let dir = root.join(format!("data/{namespace}/tags/{kind}"));
if dir.exists() {
walk(&dir, &dir, &namespace, &mut raw)?;
@@ -605,7 +605,7 @@ impl Items {
consumable: c.get("minecraft:consumable").map(|cons| {
horizon_world::consumable::Consumable {
seconds: cons["consume_seconds"].as_f64().map_or(1.6, |s| s as f32),
animation: crate::first_person::consumable_animation(cons),
animation: crate::use_animation::consumable_animation(cons),
sound: cons["sound"]
.as_str()
.unwrap_or("minecraft:entity.generic.eat")
@@ -740,7 +740,7 @@ impl Items {
let loot_root = root.join("data/minecraft/loot_table");
walk_loot(&loot_root, &loot_root, "minecraft", &mut loot)?;
// Content mods' tables (docs/modpacks.md): `<ns>:blocks/...`.
for namespace in crate::mod_content::namespaces(root) {
for namespace in crate::content_namespaces::namespaces(root) {
let dir = root.join(format!("data/{namespace}/loot_table"));
if dir.is_dir() {
walk_loot(&dir, &dir, &namespace, &mut loot)?;
@@ -779,7 +779,7 @@ impl Items {
})
}
/// An item table with only tags, for unit tests of item rules.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn for_tests(item_tags: HashMap<String, HashSet<String>>) -> Self {
Self {
items: HashMap::new(),
@@ -1754,6 +1754,22 @@ mod bucket_use_tests {
}
}
/// The items of the prepared resources, when they are on this machine, for
/// tests: the block registry and facts as the client's `Assets::load` reads them.
#[cfg(test)]
pub(crate) fn prepared_items() -> Option<Items> {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return None;
}
let (registry, _) =
BlockRegistry::from_facts_json_with_flags(fs::File::open(root.join("facts.json")).ok()?)
.ok()?;
let facts = BlockFacts::load(&root, &registry).ok()?;
Items::load(&root, &registry, &facts).ok()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1866,14 +1882,9 @@ mod tests {
/// `EnchantRandomlyFunction`) put their enchantments on the stack.
#[test]
fn loot_enchant_functions_enchant_the_rolled_item() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
let Some(items) = super::prepared_items() else {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = Items::load(&root, assets.registry, &facts).unwrap();
};
let props = BTreeMap::new();
let mut random = LegacyRandom::new(11);
// A sword at levels 20..39 comes out enchanted, as in an End city.
@@ -1932,14 +1943,9 @@ mod tests {
/// Mending book, then `set_count` uniform 1..64, is 46.
#[test]
fn a_single_level_enchantment_draws_no_level() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
let Some(items) = super::prepared_items() else {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = Items::load(&root, assets.registry, &facts).unwrap();
};
let props = BTreeMap::new();
let table = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
"name":"minecraft:book","modifier":[
@@ -1956,79 +1962,6 @@ mod tests {
// 46 is vanilla's count for this seed; an extra level draw gives 28.
assert_eq!(drops[0].count, 46);
}
#[test]
fn always_edible_and_consume_effects_loaded() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = Items::load(&root, assets.registry, &facts).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
assert!(apple.food.is_some());
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
let enchanted = items
.get("minecraft:enchanted_golden_apple")
.expect("enchanted golden apple");
assert!(enchanted.can_always_eat);
assert!(enchanted.food.is_some());
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
assert!(!beef.can_always_eat);
assert!(beef.food.is_some());
// Consumables without food, their style and their remainders.
use horizon_world::consumable::UseAnimation;
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
let drink = milk.consumable.as_ref().unwrap();
assert!(milk.food.is_none());
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
assert!(!drink.particles);
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
let sip = honey.consumable.as_ref().unwrap();
assert_eq!(sip.ticks(), 40);
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
assert_eq!(
sip.effects,
[ConsumeEffect::RemoveEffects(vec![
"minecraft:poison".into()
])]
);
assert_eq!(
honey.use_remainder.as_deref(),
Some("minecraft:glass_bottle")
);
let kelp = items.get("minecraft:dried_kelp").unwrap();
let bite = kelp.consumable.as_ref().unwrap();
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
assert!(bite.particles && kelp.use_remainder.is_none());
let chorus = items.get("minecraft:chorus_fruit").unwrap();
assert_eq!(
chorus.consumable.as_ref().unwrap().effects,
[ConsumeEffect::TeleportRandomly(
horizon_world::teleport_consume::TeleportConsume {
diameter: 16.0,
directional_particles: true,
}
)]
);
let chicken = items.get("minecraft:chicken").unwrap();
assert!(matches!(
chicken.consumable.as_ref().unwrap().effects[..],
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
}
/// Minecraft.handleKeybinds: spectators cannot drop from the selected slot.
+19
View File
@@ -0,0 +1,19 @@
//! Blocks and items as the client knows them, below gameplay: block facts
//! (`blocks`), item stacks and definitions (`items`), their data components
//! (`item_data`), enchantments, cooldowns, bundles and item stacks in NBT.
//! horizon-client re-exports each module under its old path
//! (`horizon_client::items`, ...).
#![forbid(unsafe_code)]
pub mod banner_layer;
pub mod blocks;
pub mod bundle;
pub mod content_namespaces;
pub mod cooldowns;
pub mod enchant;
pub mod firework;
pub mod item_data;
pub mod item_nbt;
pub mod items;
pub mod random_sequences;
pub mod use_animation;
pub use horizon_client_core::Result;
+14
View File
@@ -0,0 +1,14 @@
//! The use animation a `consumable` component names.
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
use horizon_world::consumable::UseAnimation;
match v
.get("animation")
.and_then(serde_json::Value::as_str)
.unwrap_or("eat")
{
"eat" => UseAnimation::Eat,
"drink" => UseAnimation::Drink,
_ => UseAnimation::None,
}
}
+4 -3
View File
@@ -5,7 +5,8 @@ counted only for the game loop because nearly every gameplay change touches it).
organised and how to build and test are in [AGENTS.md](../AGENTS.md).
Paths: `L/` = `crates/horizon-client/src/`, `G/` = `crates/horizon-client/src/game/`, `W/` =
`crates/horizon-world/src/`, `K/` = `crates/horizon-client-core/src/`, `C/` = `crates/horizon-command/src/`.
`crates/horizon-world/src/`, `K/` = `crates/horizon-client-core/src/`, `C/` = `crates/horizon-command/src/`,
`I/` = `crates/horizon-items/src/`.
Checks: a bare name is a harness (`python3 tools/parity.py <name>`, script `tools/test_<name>_parity.py`);
`lan_e2e <check>` is `python3 tools/lan_e2e.py --check <check>`.
@@ -30,7 +31,7 @@ file selects ~103.
| 12 | Guests' actions on a server (98) | `G/lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `G/lan_{attributes,pvp,chat,crafting,stations,trading,mount_menu,waypoints}.rs` | server_player_combat, server_player_damage, server_player_targets, lan_e2e (all) |
| 13 | Modpack porting, mod content (97) | `crates/horizon-port-pack/`, `L/mod_content.rs`, `tools/port_*.py` | `cargo test -p horizon-port-pack`; `python3 tools/mod_port_harness.py` |
| 14 | Mob AI (96) | `L/mobs/ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `control.rs` | mob_ai (probe `examples/mob_ai_probe.rs`; known failure on main: drowned_*, #290) |
| 15 | Items and item use (96) | `L/items.rs`, `L/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/potions.rs`, `L/effects.rs`; game.rs `fn use_hand`, `fn use_item` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect |
| 15 | Items and item use (96) | `I/items.rs`, `I/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/potions.rs`, `L/effects.rs`; game.rs `fn use_hand`, `fn use_item` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect |
| 16 | Options and settings screens (93) | `L/options_ui.rs`, `G/options.rs`, `L/settings.rs`, `L/options_file.rs`, `L/graphics_ui.rs`, `L/graphics.rs` | options, toggle_keys |
| 17 | HUD, chat, tab list (93) | `L/hud.rs`, `L/hud_layout.rs`, `L/hud_overlays.rs`, `L/chat.rs`, `G/chat.rs`, `L/tab_list.rs`, `L/locator_bar.rs`, `G/locator_bar.rs` | hud, tab_list, locator_bar, chat_screen, secure_chat |
| 18 | Command execution (88) | `G/commands.rs`, `G/commands/*.rs`, `L/scoreboard_cmd.rs`, `L/data_cmd.rs`, `K/scoreboard.rs`, `G/functions.rs` | command_exec (corpus `tools/command_exec_corpus.py`), scoreboard, server_ops_feedback, lan_e2e commands |
@@ -40,7 +41,7 @@ file selects ~103.
| 22 | Player movement and collision (75) | `L/motion.rs` + `L/motion/`, `L/collisions.rs`, `W/entity_move.rs`, `W/player_travel.rs`, `W/collision.rs` | collision, entity_move, player_travel, living_motion, climbing, swimming; `python3 tools/test_native_scene_motion.py` |
| 23 | Resource packs and models (65) | `L/packs.rs` + `L/packs/`, `L/pack_screen.rs`, `L/atlas_sources.rs`, `L/sprites.rs`, `L/model_library.rs`, `G/packs.rs`, `crates/horizon-assets/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
| 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `L/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks |
| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `L/enchant.rs`, `L/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing |
| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `I/enchant.rs`, `L/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing |
| 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter |
| 27 | Vehicles and mounts (54) | `G/boats.rs`, `G/boat_passengers.rs`, `G/minecarts.rs`, `G/mounts.rs`, `G/mount_menu.rs`, `G/llamas.rs`, `G/passengers.rs` | server_entities, lan_e2e boats minecarts riding mounts |
| 28 | Villagers, trading, raids (50) | `L/mobs/villager*.rs`, `L/merchant.rs`, `G/trading.rs`, `G/villagers.rs`, `L/raid.rs`, `G/raids.rs`, `L/gossip.rs` | trading, raid, mob_ai |
+3
View File
@@ -447,6 +447,9 @@ comment on any issue or pull request (`.github/workflows/nightly-command.yml`, `
Equal inputs give the same commit, so an unchanged set and main publish nothing new. On a conflict the build
stops and answers which two pull requests conflict and where; a reviewer can then open an integration pull
request from their fork that combines them, and pick that one instead.
- **Old builds.** The scheduled run deletes the `nightly/build-*` branches older than 7 days (Ludovic,
2026-10-27), and lists each in its summary. It never deletes the newest build, a build a published `nightly` or
`nightly-<date>` release points to, or any other branch (`nightly.py prune-builds`).
- **Refused.** A pull request that changes a path CODEOWNERS gives to Ludovic (`.github/`, `tools/ci/`, the
signing and update code, `launcher/src/backend.rs`, `launcher/links.json`…), more than 30 in a set, and more than
6 builds a day (the scheduled one always runs).
+36
View File
@@ -120,3 +120,39 @@ class UntrustedBuildTests(unittest.TestCase):
self.assertEqual(self.game.count("HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}"), calls)
env = (Path(__file__).resolve().parents[1] / "tools/ci/runner-env.sh").read_text()
self.assertEqual(env.count('-z "${HORIZON_NO_SCCACHE:-}"'), 2)
class PruneBuildsTests(unittest.TestCase):
TODAY = dt.date(2026, 11, 10)
def test_only_old_unpublished_builds_go(self):
branches = {
"nightly/build-2026-10-27-1": "a", # old: goes
"nightly/build-2026-10-27-2": "pub", # old, but a published nightly-<date> points to it: stays
"nightly/build-2026-11-02-1": "c", # old enough by one day: goes
"nightly/build-2026-11-03-1": "d", # exactly 7 days: stays
"nightly/build-2026-11-09-3": "e", # recent: stays
}
self.assertEqual(nightly.stale_builds(branches, {"pub"}, self.TODAY, 7),
["nightly/build-2026-10-27-1", "nightly/build-2026-11-02-1"])
def test_the_newest_build_always_stays(self):
branches = {"nightly/build-2026-10-01-1": "a", "nightly/build-2026-10-01-2": "b"}
self.assertEqual(nightly.stale_builds(branches, set(), self.TODAY, 7), ["nightly/build-2026-10-01-1"])
self.assertEqual(nightly.stale_builds({"nightly/build-2026-10-01-1": "a"}, set(), self.TODAY, 7), [])
# the newest by number, not by text: -10 is newer than -9
branches = {"nightly/build-2026-10-01-9": "a", "nightly/build-2026-10-01-10": "b"}
self.assertEqual(nightly.stale_builds(branches, set(), self.TODAY, 7), ["nightly/build-2026-10-01-9"])
def test_nothing_else_is_touched(self):
branches = {"nightly-state": "s", "nightly/build-x": "x", "main": "m", "nightly/build-2026-10-01-1-fix": "f",
"feature/nightly/build-2026-10-01-1": "g", "nightly/build-2026-10-02-1": "n"}
self.assertEqual(nightly.stale_builds(branches, set(), self.TODAY, 7), [])
def test_the_workflow_prunes_on_schedule_only(self):
build = (WORKFLOWS / "nightly-build.yml").read_text()
step = build.split("Old nightly/build-* branches deleted", 1)[1].split("- uses:", 1)[0]
self.assertIn("if: github.event_name == 'schedule'", step)
self.assertIn("prune-builds --days 7", step)
self.assertIn("GITHUB_STEP_SUMMARY", step)
+48
View File
@@ -4,6 +4,8 @@
nightly.py pick [--force] which commit tonight's nightly builds: GitHub outputs and nightly-notes.md
nightly.py rotate renames the current `nightly` pre-release to nightly-<its date>, frees the tag
nightly.py prune [--days 7] deletes the nightly-<date> pre-releases and tags older than that, nothing else
nightly.py prune-builds [--days 7] deletes the nightly/build-<date>-<n> branches older than that, but the
newest and any a published nightly points to (Ludovic, 2026-10-27)
A nightly builds main plus the pull requests the trusted reviewers picked (the set: tools/ci/nightly_set.py), merged
in their order and pushed as nightly/build-<date>-<n>. With no set it builds the newest union branch on origin
@@ -264,17 +266,63 @@ def prune(days: int) -> None:
print(f"deleted {tag}")
BUILD = re.compile(r"^nightly/build-(\d{4}-\d{2}-\d{2})-(\d+)$")
def stale_builds(branches: dict[str, str], published: set[str], today: dt.date, days: int) -> list[str]:
"""The nightly/build-<date>-<n> branches (name -> commit) to delete: older than `days`, never the newest build,
never one whose commit a published nightly release points to, never a branch not named like a build."""
builds = {name: sha for name, sha in branches.items() if BUILD.match(name)}
if not builds:
return []
order = lambda name: (BUILD.match(name).group(1), int(BUILD.match(name).group(2))) # noqa: E731
newest = max(builds, key=order)
cutoff = today - dt.timedelta(days=days)
return sorted(name for name, sha in builds.items() if name != newest and sha not in published
and dt.date.fromisoformat(BUILD.match(name).group(1)) < cutoff)
def remote_refs(pattern: str) -> dict[str, str]:
"""origin's refs matching `pattern`: short name -> commit (annotated tags peeled)."""
refs: dict[str, str] = {}
for line in git("ls-remote", "origin", pattern).splitlines():
sha, ref = line.split("\t", 1)
name = ref.removeprefix("refs/heads/").removeprefix("refs/tags/")
if name.endswith("^{}"):
refs[name[:-3]] = sha # the peeled commit wins over the tag object
else:
refs.setdefault(name, sha)
return refs
def prune_builds(days: int) -> None:
"""Deletes old nightly/build-* branches on origin through the API, one line per deletion (the run summary)."""
branches = remote_refs("refs/heads/nightly/build-*")
tags = {name: sha for name, sha in remote_refs("refs/tags/nightly*").items()
if name == "nightly" or DATED.match(name)}
today = dt.datetime.now(dt.timezone.utc).date()
doomed = stale_builds(branches, set(tags.values()), today, days)
for name in doomed:
gh("api", "-X", "DELETE", f"repos/{os.environ['GITHUB_REPOSITORY']}/git/refs/heads/{name}")
print(f"- deleted `{name}` ({branches[name][:7]})")
print(f"{len(doomed)} of {len(branches)} nightly/build-* branches deleted (kept: the newest, the published "
f"nightlies' and the last {days} days')")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("pick").add_argument("--force", action="store_true")
sub.add_parser("rotate")
sub.add_parser("prune").add_argument("--days", type=int, default=7)
sub.add_parser("prune-builds").add_argument("--days", type=int, default=7)
args = ap.parse_args()
if args.cmd == "pick":
pick(args.force)
elif args.cmd == "rotate":
rotate()
elif args.cmd == "prune-builds":
prune_builds(args.days)
else:
prune(args.days)
+1 -2
View File
@@ -292,8 +292,7 @@ def mod_features(crates):
# What horizon-client's full content-root tests load (mod_content_tests.rs). They take minutes on prepared data, so
# they are #[ignore]d and run here only when one of these changes.
CONTENT_ROOT = ("crates/horizon-client/src/mod_content", "crates/horizon-client/src/assets.rs",
"crates/horizon-client/src/blocks.rs", "crates/horizon-client/src/items.rs", "crates/horizon-items/",
CONTENT_ROOT = ("crates/horizon-client/src/mod_content", "crates/horizon-client/src/assets.rs", "crates/horizon-items/",
"crates/horizon-mods/", "crates/horizon-assets/")
CONTENT_ROOT_TESTS = "cargo test --locked -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored"
+1 -1
View File
@@ -13,7 +13,7 @@ def main():
resources = lab.RESOURCES
build = lab.LOCAL / "block-particles-parity"
build.mkdir(exist_ok=True)
paths = [lab.ROOT / p for p in ["tools/test_block_particles_parity.py", "tools/test_breaking_model_parity.py", "tools/oracle_harness.py", "tools/lab.py", "crates/horizon-client/src/particles.rs", "crates/horizon-client/src/game.rs", "crates/horizon-client/src/blocks.rs", "crates/horizon-client/src/mesher.rs", "crates/horizon-client/examples/probe/block_particles_fixture.rs", "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockParticlesOracle.java", "crates/horizon-core/src/random.rs", "Cargo.lock", "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaGameplayFactsExporter.java"]]
paths = [lab.ROOT / p for p in ["tools/test_block_particles_parity.py", "tools/test_breaking_model_parity.py", "tools/oracle_harness.py", "tools/lab.py", "crates/horizon-client/src/particles.rs", "crates/horizon-client/src/game.rs", "crates/horizon-items/src/blocks.rs", "crates/horizon-client/src/mesher.rs", "crates/horizon-client/examples/probe/block_particles_fixture.rs", "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaBlockParticlesOracle.java", "crates/horizon-core/src/random.rs", "Cargo.lock", "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaGameplayFactsExporter.java"]]
tested = base.source_hashes(paths)
rows = [dict(case=i, seed=(-1 if i % 2 else 1) * i * 987654321, **row) for i, row in enumerate(breaking.cases(resources))]
rows[0]["seed"] = -(2**63)
+1 -1
View File
@@ -28,7 +28,7 @@ def main():
'Item.canDestroyBlock with each vanilla item, empty stacks, Survival/Creative '
'abilities, and explicit/implicit tool overrides. Block destruction and network '
'prediction call this predicate; end-to-end packet handling is not covered here.',
sources=[lab.ROOT / 'crates/horizon-client/src/items.rs'])
sources=[lab.ROOT / 'crates/horizon-items/src/items.rs'])
if __name__ == '__main__':
+1 -1
View File
@@ -25,7 +25,7 @@ def main():
'FireChargeItem.useOn\'s choice between lighting and placing fire is not exercised.')
harness.differential(
'rust-fire-starter-sound-parity', 'VanillaFireStarterSoundOracle', 'fire_starter_sound_probe', make_cases,
scope, sources=[lab.ROOT / 'crates/horizon-client/src/items.rs'])
scope, sources=[lab.ROOT / 'crates/horizon-items/src/items.rs'])
if __name__ == '__main__':
+2 -2
View File
@@ -67,7 +67,7 @@ if __name__ == '__main__':
'firework-tooltip-parity', 'VanillaFireworkTooltipOracle', 'firework_tooltip_fixture', cases,
'Firework rocket and star tooltip lines: flight duration, explosions (counted runs), colours, fade, trail, '
'twinkle, defaults and tooltip_display',
sources=[lab.ROOT / 'crates/horizon-client/src/enchant.rs',
lab.ROOT / 'crates/horizon-client/src/item_tooltip.rs', lab.ROOT / 'crates/horizon-client/src/item_data.rs',
sources=[lab.ROOT / 'crates/horizon-items/src/enchant.rs',
lab.ROOT / 'crates/horizon-client/src/item_tooltip.rs', lab.ROOT / 'crates/horizon-items/src/item_data.rs',
lab.ROOT / 'crates/horizon-client/src/particles.rs'],
expected_lines=COUNT)
+1 -1
View File
@@ -34,7 +34,7 @@ if __name__ == '__main__':
'Item lore tooltip lines: dark purple italic, child colours, explicit non-italic, and tooltip_display',
sources=[
lab.ROOT / 'crates/horizon-client/src/item_tooltip.rs',
lab.ROOT / 'crates/horizon-client/src/item_data.rs',
lab.ROOT / 'crates/horizon-items/src/item_data.rs',
lab.ROOT / 'crates/horizon-client-core/src/text.rs',
lab.ROOT / 'crates/horizon-client-core/src/component.rs',
],