Merge main into fix/bundle-dump-on-use
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 { "" };
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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);
|
||||
@@ -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, ®istry).ok()?;
|
||||
Items::load(&root, ®istry, &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.
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user