mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'upstream/main' into hook-fragments-workflow
This commit is contained in:
commit
d8882f9cb4
162 files changed
+7410
-778
No files matched your search
@@ -16,6 +16,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
mod component_predicate;
|
||||
mod special;
|
||||
pub use special::{SpecialKind, SpecialModel};
|
||||
|
||||
@@ -628,7 +629,7 @@ pub enum ConditionProperty {
|
||||
ExtendedView,
|
||||
KeybindDown(String),
|
||||
ViewEntity,
|
||||
/// A `component` predicate, evaluated by the host.
|
||||
/// A single `component` predicate; external predicate types use the host.
|
||||
Component(Value),
|
||||
}
|
||||
impl ConditionProperty {
|
||||
@@ -651,13 +652,7 @@ impl ConditionProperty {
|
||||
"minecraft:extended_view" => Self::ExtendedView,
|
||||
"minecraft:keybind_down" => Self::KeybindDown(string(required(m, "keybind")?)?),
|
||||
"minecraft:view_entity" => Self::ViewEntity,
|
||||
"minecraft:component" => {
|
||||
let predicate = required(m, "predicate")?;
|
||||
if object(predicate)?.len() != 1 {
|
||||
return Err(err("a single component predicate is expected"));
|
||||
}
|
||||
Self::Component(predicate.clone())
|
||||
}
|
||||
"minecraft:component" => Self::Component(component_predicate::decode(m)?),
|
||||
_ => return Err(err("unknown condition property")),
|
||||
})
|
||||
}
|
||||
@@ -964,10 +959,14 @@ pub trait ItemEnv {
|
||||
fn bundle_fullness(&self, _stack: &StackView, _components: &Components) -> f32 {
|
||||
0.0
|
||||
}
|
||||
/// `ItemStack.getMaxStackSize` when the prototype has no component.
|
||||
/// Additional component predicate types supplied by the host.
|
||||
fn component_matches(&self, _stack: &StackView, _predicate: &Value) -> bool {
|
||||
false
|
||||
}
|
||||
/// Membership in the level's enchantment registry tags.
|
||||
fn enchantment_in_tag(&self, _enchantment: &str, _tag: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn is_keybind_down(&self, _keybind: &str) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -1335,7 +1334,8 @@ impl Resolve<'_> {
|
||||
ConditionProperty::KeybindDown(key) => self.env.is_keybind_down(key),
|
||||
ConditionProperty::ViewEntity => owner.is_some_and(|o| o.is_view_entity),
|
||||
ConditionProperty::Component(predicate) => {
|
||||
self.env.component_matches(self.stack, predicate)
|
||||
component_predicate::matches(predicate, self.components, self.env)
|
||||
.unwrap_or_else(|| self.env.component_matches(self.stack, predicate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Component conditions used by enchantment-dependent item models.
|
||||
use super::*;
|
||||
|
||||
/// `DataComponentPredicate.singleCodec`: a single predicate's type discriminator
|
||||
/// (`predicate`) and its wrapped codec's separate `value` field.
|
||||
pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
|
||||
let id = component_type(required(m, "predicate")?)?;
|
||||
let mut value = required(m, "value")?.clone();
|
||||
if matches!(
|
||||
id.as_str(),
|
||||
"minecraft:enchantments" | "minecraft:stored_enchantments"
|
||||
) {
|
||||
let mut entries = Vec::new();
|
||||
for entry in list(&value)? {
|
||||
let mut entry = object(entry)?.clone();
|
||||
if let Some(selector) = get(&entry, "enchantments") {
|
||||
let selector = match selector {
|
||||
Value::Array(ids) => Value::Array(
|
||||
ids.iter()
|
||||
.map(ident)
|
||||
.map(|r| r.map(Value::String))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
Value::String(name) if name.starts_with('#') => {
|
||||
Value::String(format!("#{}", identifier(&name[1..])?))
|
||||
}
|
||||
_ => Value::String(ident(selector)?),
|
||||
};
|
||||
entry.insert("enchantments".into(), selector);
|
||||
}
|
||||
if let Some(levels) = get(&entry, "levels") {
|
||||
let levels = if levels.is_object() {
|
||||
let mut bounds = Map::new();
|
||||
for key in ["min", "max"] {
|
||||
if let Some(n) = get(object(levels)?, key) {
|
||||
bounds.insert(key.into(), Value::from(int(n)?));
|
||||
}
|
||||
}
|
||||
// MinMaxBounds.Bounds.validateSwappedBoundsInCodec.
|
||||
if bounds
|
||||
.get("min")
|
||||
.and_then(Value::as_i64)
|
||||
.zip(bounds.get("max").and_then(Value::as_i64))
|
||||
.is_some_and(|(min, max)| min > max)
|
||||
{
|
||||
return Err(err("minimum exceeds maximum"));
|
||||
}
|
||||
Value::Object(bounds)
|
||||
} else {
|
||||
Value::from(int(levels)?)
|
||||
};
|
||||
entry.insert("levels".into(), levels);
|
||||
}
|
||||
entries.push(Value::Object(entry));
|
||||
}
|
||||
value = Value::Array(entries);
|
||||
}
|
||||
Ok(Value::Object(Map::from_iter([(id, value)])))
|
||||
}
|
||||
|
||||
/// `EnchantmentsPredicate.matches` and `EnchantmentPredicate.containedIn`:
|
||||
/// all entries must match; each entry needs one selected, present enchantment
|
||||
/// with a matching level. An empty predicate list still needs the component.
|
||||
pub(super) fn matches(
|
||||
predicate: &Value,
|
||||
components: &Components,
|
||||
env: &dyn ItemEnv,
|
||||
) -> Option<bool> {
|
||||
let (id, expected) = predicate.as_object()?.iter().next()?;
|
||||
if !matches!(
|
||||
id.as_str(),
|
||||
"minecraft:enchantments" | "minecraft:stored_enchantments"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let Some(have) = components.get(id).and_then(Value::as_object) else {
|
||||
return Some(false);
|
||||
};
|
||||
Some(expected.as_array()?.iter().all(|entry| {
|
||||
let Some(entry) = entry.as_object() else {
|
||||
return false;
|
||||
};
|
||||
have.iter().any(|(id, level)| {
|
||||
let level = level.as_i64().unwrap_or(0);
|
||||
let selector = get(entry, "enchantments");
|
||||
let selected = match selector {
|
||||
None => true,
|
||||
Some(Value::String(s)) => s
|
||||
.strip_prefix('#')
|
||||
.map_or(s == id, |tag| env.enchantment_in_tag(id, tag)),
|
||||
Some(Value::Array(ids)) => ids.iter().any(|s| s.as_str() == Some(id)),
|
||||
_ => false,
|
||||
};
|
||||
selected
|
||||
&& if selector.is_some() {
|
||||
matches_enchantment(level, get(entry, "levels"))
|
||||
} else {
|
||||
level_matches(level, get(entry, "levels"))
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// `EnchantmentPredicate.matchesEnchantment`: a named enchantment needs a
|
||||
/// nonzero level even when its bounds accept zero.
|
||||
fn matches_enchantment(level: i64, bounds: Option<&Value>) -> bool {
|
||||
level != 0 && level_matches(level, bounds)
|
||||
}
|
||||
|
||||
/// `MinMaxBounds.Ints.matches`: inclusive bounds, or an exact integer.
|
||||
fn level_matches(level: i64, bounds: Option<&Value>) -> bool {
|
||||
match bounds {
|
||||
None => true,
|
||||
Some(Value::Number(n)) => n.as_i64() == Some(level),
|
||||
Some(Value::Object(bounds)) => {
|
||||
get(bounds, "min")
|
||||
.and_then(Value::as_i64)
|
||||
.is_none_or(|min| level >= min)
|
||||
&& get(bounds, "max")
|
||||
.and_then(Value::as_i64)
|
||||
.is_none_or(|max| level <= max)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
struct Tags;
|
||||
impl ItemEnv for Tags {
|
||||
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
|
||||
enchantment == "minecraft:density" && tag == "horizon:mace"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_predicates_inspect_entries_but_named_ones_require_a_nonzero_level() {
|
||||
let components = Components::from_iter([(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":0}),
|
||||
)]);
|
||||
assert_eq!(
|
||||
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
|
||||
&components,
|
||||
&NoEnv
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_selectors_use_the_hosts_registry_tags() {
|
||||
let condition = json!({"predicate":"enchantments", "value":[
|
||||
{"enchantments":"#horizon:mace", "levels":{"min":3}}
|
||||
]});
|
||||
let predicate = decode(condition.as_object().unwrap()).unwrap();
|
||||
let mut components = Components::from_iter([(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":3}),
|
||||
)]);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
|
||||
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
|
||||
components.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":2}),
|
||||
);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,9 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
"assets/horizon/lang/zh_cn.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/zh_cn.json")
|
||||
}
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
@@ -288,6 +291,16 @@ mod tests {
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
let zh = Language::load(&[&empty], "zh_cn");
|
||||
assert_eq!(zh.get("horizon.options.controller"), Some("手柄"));
|
||||
assert_eq!(zh.get("horizon.pages.mods"), Some("模组"));
|
||||
assert_eq!(zh.get("Preset"), Some("预设"));
|
||||
assert_eq!(zh.get("%s chunks"), Some("%s 个区块"));
|
||||
// Not every bundle is complete: the en_us layer is in every map, so a
|
||||
// missing key falls back to English (`horizon.mods.not_built` is not
|
||||
// in ru_ru either).
|
||||
assert_eq!(zh.get("LOD Distance"), Some("LOD 距离"));
|
||||
assert_eq!(ru.get("horizon.mods.not_built"), Some(" (Not built)"));
|
||||
let pl = Language::load(&[&empty], "pl_pl");
|
||||
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
|
||||
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
|
||||
|
||||
@@ -272,10 +272,15 @@ pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
let Some(def) = id.and_then(potion) else {
|
||||
return BASE_POTION_COLOR;
|
||||
};
|
||||
effects_color(def.effects.iter().map(|(id, _, amp)| (*id, *amp)))
|
||||
}
|
||||
/// `PotionContents.getColorOptional` of some effects, `(id, amplifier)`, all visible: the mix of
|
||||
/// their colours weighted by amplifier + 1, else the base potion colour.
|
||||
pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
|
||||
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
|
||||
for (e, _, amp) in def.effects {
|
||||
for (e, amp) in effects {
|
||||
let Some(e) = effect(e) else { continue };
|
||||
let w = *amp as u32 + 1;
|
||||
let w = amp as u32 + 1;
|
||||
r += w * (e.color >> 16 & 255);
|
||||
g += w * (e.color >> 8 & 255);
|
||||
b += w * (e.color & 255);
|
||||
@@ -301,6 +306,21 @@ mod tests {
|
||||
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
|
||||
}
|
||||
|
||||
/// `PotionContents.getColorOptional`: each visible effect's colour weighted by its
|
||||
/// amplifier + 1, the channels averaged by integer division; none gives the base colour.
|
||||
#[test]
|
||||
fn effects_mix_their_colours_by_amplifier() {
|
||||
let c = |id: &str| effect(id).unwrap().color;
|
||||
assert_eq!(effects_color([("poison", 0)].into_iter()), c("poison"));
|
||||
let (p, s) = (c("poison"), c("speed"));
|
||||
let mix = |shift: u32| (((p >> shift & 255) + 3 * (s >> shift & 255)) / 4) << shift;
|
||||
assert_eq!(
|
||||
effects_color([("poison", 0), ("speed", 2)].into_iter()),
|
||||
mix(16) | mix(8) | mix(0)
|
||||
);
|
||||
assert_eq!(effects_color(std::iter::empty()), BASE_POTION_COLOR);
|
||||
}
|
||||
|
||||
/// The only test that registers: the registry is set once per process.
|
||||
#[test]
|
||||
fn mod_effects_follow_vanilla_once_registered() {
|
||||
|
||||
@@ -1074,6 +1074,15 @@ impl Audio {
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
// The device belongs to the first player's output, which looks after it.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: self.default_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
cards_seen: self.cards_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: self.devices_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
}
|
||||
}
|
||||
/// Sync the volume options, refreshing the categories that changed
|
||||
@@ -1098,6 +1107,11 @@ impl Audio {
|
||||
/// at once a second.
|
||||
#[cfg(feature = "audio")]
|
||||
fn should_change_device(&mut self) -> bool {
|
||||
// A splitscreen share plays through the first player's output: it leaves the device,
|
||||
// and the mixer's lost flag, to that output.
|
||||
if self.backend.owner != 0 {
|
||||
return false;
|
||||
}
|
||||
let (current, lost) = match self.backend.mixer.lock() {
|
||||
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
|
||||
Err(_) => return false,
|
||||
|
||||
@@ -77,6 +77,7 @@ mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod native_material;
|
||||
@@ -3563,7 +3564,13 @@ impl Renderer {
|
||||
) {
|
||||
surface.configure(device, config);
|
||||
if let Some(hdr) = hdr {
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
horizon_hdr::set_metadata(
|
||||
device,
|
||||
surface,
|
||||
hdr.paper_white,
|
||||
hdr.declared_peak(),
|
||||
hdr.black,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn configure_surface(&self) {
|
||||
@@ -3576,9 +3583,29 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// HDR calibration's test picture (`crate::hdr_calibration`) while it runs, else None.
|
||||
pub fn set_hdr_calibration(&mut self, calibration: Option<HdrCalibration>) {
|
||||
let Some(hdr) = &mut self.hdr else {
|
||||
return;
|
||||
};
|
||||
if hdr.calibration == calibration {
|
||||
return;
|
||||
}
|
||||
let declared = hdr.declared_peak();
|
||||
hdr.calibration = calibration;
|
||||
if hdr.declared_peak() != declared {
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
fn send_hdr_metadata(&self) {
|
||||
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
horizon_hdr::set_metadata(
|
||||
&self.device,
|
||||
surface,
|
||||
hdr.paper_white,
|
||||
hdr.declared_peak(),
|
||||
hdr.black,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
@@ -3639,7 +3666,8 @@ impl Renderer {
|
||||
DisplayWatch::default()
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.hdr =
|
||||
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
|
||||
self.configure_surface();
|
||||
match output {
|
||||
Some(output) => log::info!(
|
||||
|
||||
@@ -91,9 +91,22 @@ pub const TIERS: [Tier; 4] = [
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
/// A step of HDR calibration (`crate::hdr_calibration`): its test picture is drawn in nits
|
||||
/// above the controls instead of the frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Calibration {
|
||||
/// 1 black level, 2 peak brightness, 3 paper white.
|
||||
pub step: u32,
|
||||
/// The slider's value, in nits.
|
||||
pub nits: f32,
|
||||
/// Where the controls start, as a share of the height.
|
||||
pub band: f32,
|
||||
}
|
||||
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// Horizon's logo as a 1-bit mask, for calibration's test pictures.
|
||||
logo: wgpu::TextureView,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
|
||||
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
|
||||
@@ -120,10 +133,17 @@ pub struct Hdr {
|
||||
pub tiers: [Tier; 4],
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
output: Output,
|
||||
) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
|
||||
@@ -143,6 +163,7 @@ impl Hdr {
|
||||
entries: &[
|
||||
texture(0),
|
||||
texture(1),
|
||||
texture(3),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
@@ -232,12 +253,13 @@ impl Hdr {
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
size: 96,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
layout,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
clear_alpha,
|
||||
@@ -254,6 +276,7 @@ impl Hdr {
|
||||
black: 0.0,
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +321,10 @@ impl Hdr {
|
||||
binding: 2,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
@@ -310,6 +337,16 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
|
||||
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
|
||||
/// patch through as bright as the display goes and the logo under it shows.
|
||||
pub fn declared_peak(&self) -> f32 {
|
||||
match self.calibration {
|
||||
Some(c) if c.step == 2 => 10_000.0,
|
||||
_ => self.peak,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
@@ -430,6 +467,10 @@ impl Hdr {
|
||||
binding: 2,
|
||||
resource: self.capture_params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
});
|
||||
queue.write_buffer(
|
||||
@@ -442,9 +483,10 @@ impl Hdr {
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let mut params = [0.0; 20];
|
||||
let c = self.calibration;
|
||||
let mut params = [0.0; 24];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
@@ -459,6 +501,9 @@ impl Hdr {
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
params[20] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[21] = c.map_or(0.0, |c| c.nits);
|
||||
params[22] = c.map_or(0.0, |c| c.band);
|
||||
params
|
||||
}
|
||||
|
||||
@@ -489,6 +534,47 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
|
||||
/// edges, are the logo; everything else is not.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if p[3] > 128 && p[..3].iter().all(|&c| c > 200) {
|
||||
255
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
device
|
||||
.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon HDR calibration logo"),
|
||||
size: wgpu::Extent3d {
|
||||
width: w,
|
||||
height: h,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&mask,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
|
||||
@@ -128,6 +128,9 @@ struct Graph {
|
||||
/// (`Node.sourceDirections`, as the faces rather than the steps); none for
|
||||
/// the camera's.
|
||||
entered: Vec<u8>,
|
||||
/// Whether any section in the searched top layer was reached. Sections
|
||||
/// above a clamped box use this conservative sky-band answer.
|
||||
sky_reached: bool,
|
||||
/// Per section corner, during a full search: `SEES`, or how far the line
|
||||
/// from it towards the camera is known to stay in reached sections (the
|
||||
/// first sample that was not). Sections are only ever added during a
|
||||
@@ -156,8 +159,22 @@ impl Graph {
|
||||
)
|
||||
}
|
||||
fn contains(&self, key: SectionKey) -> bool {
|
||||
self.index(key)
|
||||
.is_some_and(|i| self.directions[i] & REACHED != 0)
|
||||
if let Some(i) = self.index(key) {
|
||||
self.directions[i] & REACHED != 0
|
||||
} else if (0..=self.sections).contains(&key.1)
|
||||
&& key.1 >= self.y0 + self.height
|
||||
&& (key.0 - self.from.0).abs() <= self.radius
|
||||
&& (key.2 - self.from.2).abs() <= self.radius
|
||||
{
|
||||
// Everything above the highest non-air section is open sky. A
|
||||
// full search can reach that layer from a neighbouring column,
|
||||
// so checking only this column's top cell can hide an upper
|
||||
// section that is visible through the open layer. Once any top
|
||||
// cell is reached, conservatively keep the whole sky band visible.
|
||||
self.sky_reached
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// The full search from the camera (vanilla's `scheduleFullUpdate`).
|
||||
fn search(
|
||||
@@ -174,13 +191,37 @@ impl Graph {
|
||||
self.min_y = columns.min_y;
|
||||
self.sections = columns.sections;
|
||||
self.y0 = -1;
|
||||
self.height = columns.sections + 2;
|
||||
if from.1 >= columns.sections {
|
||||
self.height = columns.sections + 2;
|
||||
} else {
|
||||
let mut max_non_air_y = 0;
|
||||
for dz in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let Some(column) = columns.map.get(&(from.0 + dx, from.2 + dz)) else {
|
||||
continue;
|
||||
};
|
||||
for (y, &v) in column.iter().enumerate().rev() {
|
||||
let y = y as i32;
|
||||
if y <= max_non_air_y {
|
||||
break;
|
||||
}
|
||||
if v != ALL_VISIBLE {
|
||||
max_non_air_y = y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let top_y = max_non_air_y.max(from.1);
|
||||
self.height = (top_y + 3).max(3).min(columns.sections + 2);
|
||||
}
|
||||
let side = (2 * radius + 1) as usize;
|
||||
let size = side * side * self.height as usize;
|
||||
self.directions.clear();
|
||||
self.directions.resize(size, 0);
|
||||
self.entered.clear();
|
||||
self.entered.resize(size, 0);
|
||||
self.sky_reached = false;
|
||||
self.rays.clear();
|
||||
self.rays
|
||||
.resize((side + 1) * (side + 1) * (self.height + 1) as usize, 0);
|
||||
@@ -194,7 +235,8 @@ impl Graph {
|
||||
let base = self
|
||||
.index((from.0 + dx, 0, from.2 + dz))
|
||||
.expect("column inside the searched box");
|
||||
vis[base..base + column.len()].copy_from_slice(column);
|
||||
let len = (column.len() as i32).min(self.height - 1) as usize;
|
||||
vis[base..base + len].copy_from_slice(&column[..len]);
|
||||
}
|
||||
}
|
||||
queue.clear();
|
||||
@@ -279,6 +321,9 @@ impl Graph {
|
||||
while let Some(index) = queue.pop_front() {
|
||||
let i = index as usize;
|
||||
let key = self.key(i);
|
||||
if key.1 == self.y0 + self.height - 1 {
|
||||
self.sky_reached = true;
|
||||
}
|
||||
let directions = self.directions[i];
|
||||
let entered = self.entered[i];
|
||||
let exits = if entered == 0 {
|
||||
@@ -1082,6 +1127,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A clamped search may conservatively show sections above its searched
|
||||
/// box, but it must never hide one that the full-height search reaches.
|
||||
#[test]
|
||||
fn vertical_clamping_only_adds_overdraw() {
|
||||
let mut compared = 0;
|
||||
let mut overdraw = 0;
|
||||
let mut clamped = 0;
|
||||
for seed in [0x9e37_79b9, 7, 0xdead_beef, 0x2545_f491] {
|
||||
for (spread, count, radius) in [(6, 900, 6), (12, 2600, 12)] {
|
||||
let mut o = Occlusion::new(0, 24);
|
||||
random_world(&mut o, seed, count, spread);
|
||||
let camera = at((0, 2, 0)) + DVec3::new(3.3, -5.7, 1.9);
|
||||
o.settle(camera, radius);
|
||||
let graph = o.graph.as_ref().expect("searched");
|
||||
clamped += usize::from(graph.height < o.columns.sections + 2);
|
||||
let full = reference(&o.columns, camera, radius, true);
|
||||
let from = o.columns.section_of(camera);
|
||||
let side = 2 * radius + 1;
|
||||
let height = o.columns.sections + 2;
|
||||
let full_contains = |key: SectionKey| {
|
||||
let (dx, dz, dy) = (key.0 - from.0, key.2 - from.2, key.1 + 1);
|
||||
if dx.abs() > radius || dz.abs() > radius || !(0..height).contains(&dy) {
|
||||
return false;
|
||||
}
|
||||
full[(((dz + radius) * side + dx + radius) * height + dy) as usize]
|
||||
};
|
||||
for x in from.0 - radius..=from.0 + radius {
|
||||
for z in from.2 - radius..=from.2 + radius {
|
||||
for y in -1..=o.columns.sections {
|
||||
let key = (x, y, z);
|
||||
let expected = full_contains(key);
|
||||
let actual = o.contains(key);
|
||||
compared += 1;
|
||||
assert!(
|
||||
!expected || actual,
|
||||
"clamped search hid {key:?} for seed {seed:#x}, spread {spread}, radius {radius}; from={from:?}, y0={}, height={}, expected={expected}, actual={actual}",
|
||||
graph.y0,
|
||||
graph.height
|
||||
);
|
||||
overdraw += usize::from(actual && !expected);
|
||||
if actual && !expected {
|
||||
assert!(
|
||||
y >= graph.y0 + graph.height,
|
||||
"overdraw must be above the clamped box: {key:?}, y0={}, height={}",
|
||||
graph.y0,
|
||||
graph.height
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(clamped > 0, "the comparison exercised a clamped search");
|
||||
assert!(compared > 50_000, "compared {compared} sections");
|
||||
assert!(overdraw > 0, "the conservative top-cell rule was exercised");
|
||||
}
|
||||
|
||||
/// A line checked again from the sample it failed at, after more
|
||||
/// sections were reached, ends where checking it from its corner does.
|
||||
#[test]
|
||||
@@ -1226,4 +1329,26 @@ mod tests {
|
||||
assert_eq!(graph.entered, local.entered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_clamping_bounds_height_and_reaches_open_sky() {
|
||||
let mut o = Occlusion::new(0, 24);
|
||||
o.set((0, 5, 0), 0); // Wall at section 5
|
||||
o.settle(at((0, 2, 0)), 4);
|
||||
let graph = o.graph.as_ref().expect("searched");
|
||||
assert_eq!(graph.height, 5 + 3);
|
||||
assert!(o.contains((0, 2, 0)), "camera section is reached");
|
||||
assert!(o.contains((1, 20, 1)), "open air column is reached");
|
||||
|
||||
// An enclosed cave cannot reach the sky
|
||||
let mut cave = Occlusion::new(0, 24);
|
||||
for (dx, dy, dz) in STEPS {
|
||||
cave.set((dx, 1 + dy, dz), 0);
|
||||
}
|
||||
cave.settle(at((0, 1, 0)), 4);
|
||||
assert!(
|
||||
!cave.contains((0, 10, 0)),
|
||||
"enclosed cave does not see the sky"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -501,7 +501,7 @@ impl Graphics {
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
n => n.clamp(80, 480),
|
||||
n => n.clamp(10, 480),
|
||||
}
|
||||
}
|
||||
"hdrPeak" => {
|
||||
@@ -719,7 +719,7 @@ mod tests {
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
|
||||
@@ -46,6 +46,8 @@ pub enum H {
|
||||
HdrBlackLevel,
|
||||
/// Binds `Graphics::hdr_key`.
|
||||
HdrKey,
|
||||
/// Starts HDR calibration (`crate::hdr_calibration`).
|
||||
HdrCalibrate,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
@@ -136,6 +138,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrCalibrate,
|
||||
None,
|
||||
"Sets the three below full screen, one test picture at a time.",
|
||||
),
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
@@ -384,6 +391,46 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Whether the system set HDR up for the display, and whether the player kept it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Setup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
fn setup(s: &Settings) -> Setup {
|
||||
setup_of(&s.horizon, crate::graphics::reported().is_some())
|
||||
}
|
||||
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
|
||||
if !reported {
|
||||
Setup::Unknown
|
||||
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
|
||||
Setup::Automatic
|
||||
} else {
|
||||
Setup::Changed
|
||||
}
|
||||
}
|
||||
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
|
||||
fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
match (item, setup(s)) {
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
fn on_off(on: bool) -> String {
|
||||
lang::get(if on { "On" } else { "Off" })
|
||||
}
|
||||
@@ -415,11 +462,8 @@ impl H {
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
// Auto, then 80..480 by 10.
|
||||
H::HdrPaperWhite => Some(match g.hdr_paper_white {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 70.0) / 410.0,
|
||||
}),
|
||||
// Auto, then 10..480 by 10.
|
||||
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
|
||||
// Auto, then 200..2000 by 50.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
@@ -483,6 +527,11 @@ impl H {
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
Setup::Changed => "Use Automatic Setup",
|
||||
})),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
@@ -533,12 +582,7 @@ impl H {
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::HdrPaperWhite => {
|
||||
g.hdr_paper_white = match (v * 41.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 70 + step * 10,
|
||||
}
|
||||
}
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
0 => 0,
|
||||
@@ -633,7 +677,11 @@ impl Item {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(
|
||||
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
|
||||
H::HdrCalibrate
|
||||
| H::HdrBlackLevel
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
_ => None,
|
||||
}
|
||||
@@ -742,6 +790,8 @@ pub enum Action {
|
||||
OpenHdr,
|
||||
/// Wait for a key or mouse button to bind to this name.
|
||||
BindKey(&'static str),
|
||||
/// Start HDR calibration.
|
||||
Calibrate,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -936,7 +986,7 @@ impl GraphicsUi {
|
||||
wd.x + wd.w + STYLE.gap,
|
||||
wd.y + 1.0,
|
||||
cost,
|
||||
text,
|
||||
description(item, s, text),
|
||||
);
|
||||
}
|
||||
Target::Button(action) => {
|
||||
@@ -1029,6 +1079,14 @@ impl GraphicsUi {
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrCalibrate)) if setup(s) == Setup::Changed => {
|
||||
s.horizon.hdr_peak = 0;
|
||||
s.horizon.hdr_paper_white = 0;
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrCalibrate)) => {
|
||||
vec![Action::ButtonClick, Action::Calibrate]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrKey)) => {
|
||||
vec![
|
||||
Action::ButtonClick,
|
||||
@@ -1286,9 +1344,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_follows_what_the_system_reported() {
|
||||
let mut g = crate::graphics::Graphics::default();
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
assert_eq!(setup_of(&g, true), Setup::Automatic);
|
||||
g.hdr_paper_white = 80;
|
||||
assert_eq!(setup_of(&g, true), Setup::Changed);
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_advanced_setting_has_a_description() {
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
|
||||
for row in rows(level) {
|
||||
if let Row::Setting(s) = row {
|
||||
assert!(!s.text.is_empty(), "{:?}", s.item);
|
||||
|
||||
@@ -26,8 +26,14 @@ struct Params {
|
||||
share: vec4<f32>,
|
||||
start: vec4<f32>,
|
||||
full: vec4<f32>,
|
||||
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
|
||||
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
|
||||
// a share of the height.
|
||||
calibration: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@group(0) @binding(3) var logo: texture_2d<f32>;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
@@ -81,6 +87,48 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
|
||||
}
|
||||
|
||||
// Whether `p` falls on the logo drawn `size` pixels wide around `centre`.
|
||||
fn on_logo(p: vec2<f32>, centre: vec2<f32>, size: f32) -> bool {
|
||||
let uv = (p - centre) / size + 0.5;
|
||||
if any(uv < vec2(0.0)) || any(uv >= vec2(1.0)) {
|
||||
return false;
|
||||
}
|
||||
let texels = vec2<f32>(textureDimensions(logo));
|
||||
return textureLoad(logo, vec2<i32>(uv * texels), 0).r > 0.5;
|
||||
}
|
||||
|
||||
// A calibration step's test picture at `p`, in nits, the way display calibration
|
||||
// tools draw them: nothing in it is bright over a large area, so the display
|
||||
// doesn't dim itself while it is judged.
|
||||
fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
let top = size.y * params.calibration.z;
|
||||
let centre = vec2(size.x, top) / 2.0;
|
||||
let v = params.calibration.y;
|
||||
switch u32(params.calibration.x) {
|
||||
// Black level: the logo at the slider on 0. Lowered until it disappears,
|
||||
// it sits on the darkest the display shows.
|
||||
case 1u: {
|
||||
return select(0.0, v, on_logo(p, centre, top * 0.4));
|
||||
}
|
||||
// Peak: a tenth of the screen at 10 000 nits, which the display clips at
|
||||
// its brightest, the logo inside at the slider. Raised until it
|
||||
// disappears, it sits on that brightest.
|
||||
case 2u: {
|
||||
let side = sqrt(0.1 * size.x * size.y);
|
||||
if all(abs(p - centre) < vec2(side / 2.0)) {
|
||||
return select(10000.0, v, on_logo(p, centre, side * 0.7));
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
// Paper white: a white card at the slider, on a dim grey, to match a
|
||||
// sheet of paper held beside the screen.
|
||||
default: {
|
||||
let card = vec2(top * 0.9, top * 0.6);
|
||||
return select(3.0, v, all(abs(p - centre) < card / 2.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
@@ -93,6 +141,16 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
|
||||
let nits = vec3(calibration_nits(pos.xy, size));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
let f = textureLoad(frame, at, 0);
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! HDR calibration (Graphics: HDR, Calibrate...): full screen, one step at a time, the way
|
||||
//! display calibration tools do it. HDR output draws each step's test picture straight in
|
||||
//! nits above a band of controls (`hdr.wgsl`, `Hdr::calibration`), past everything that
|
||||
//! shapes the game's picture, so what the player sees is the display alone:
|
||||
//!
|
||||
//! - Black Level: Horizon's logo at the slider's brightness on 0 nits; lowered until it
|
||||
//! disappears, it sits on the darkest the display shows.
|
||||
//! - Peak Brightness: a patch of a tenth of the screen at 10 000 nits (the display clips
|
||||
//! it at its brightest), the logo inside at the slider; raised until it disappears, it
|
||||
//! sits on that brightest. Small, so the display doesn't dim a bright full screen; the
|
||||
//! HDR metadata declares PQ's 10 000 nits meanwhile, so a compositor that clips at the
|
||||
//! declared peak (KWin) lets the patch through.
|
||||
//! - Paper White: a white card at the slider, matched to a sheet of white paper held
|
||||
//! beside the screen in the light the player plays in: white is where the eyes have
|
||||
//! adapted to, lower in a dark room than by day (as ITU-R BT.2408's reference white).
|
||||
//!
|
||||
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
|
||||
//! or Escape leaves them as they were.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
settings::Settings,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Step {
|
||||
Black,
|
||||
Peak,
|
||||
PaperWhite,
|
||||
}
|
||||
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
|
||||
|
||||
/// What the options screen does after an input here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Nothing,
|
||||
Click,
|
||||
Changed,
|
||||
Save,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub struct Calibration {
|
||||
step: usize,
|
||||
/// Nits, in the order of `STEPS`.
|
||||
values: [f32; 3],
|
||||
/// Which values were Auto (peak, paper white): saved as Auto again unless the
|
||||
/// player moved them.
|
||||
auto: [Option<f32>; 2],
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
/// Height of the band of controls at the bottom, in GUI pixels.
|
||||
const BAND: f32 = 84.0;
|
||||
|
||||
impl Step {
|
||||
/// Range and step of the slider, in nits: the settings' own.
|
||||
fn range(self) -> (f32, f32, f32) {
|
||||
match self {
|
||||
Step::Black => (0.0, 1.0, 0.01),
|
||||
Step::Peak => (200.0, 2000.0, 50.0),
|
||||
Step::PaperWhite => (10.0, 480.0, 10.0),
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Black Level",
|
||||
Step::Peak => "Peak Brightness",
|
||||
Step::PaperWhite => "Paper White",
|
||||
}
|
||||
}
|
||||
fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Lower it until the logo just disappears into the black.",
|
||||
Step::Peak => "Raise it until the logo just disappears into the bright square.",
|
||||
Step::PaperWhite => {
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `hdr::Calibration::step`'s number.
|
||||
pub fn number(self) -> u32 {
|
||||
match self {
|
||||
Step::Black => 1,
|
||||
Step::Peak => 2,
|
||||
Step::PaperWhite => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
top: f32,
|
||||
slider: (f32, f32, f32),
|
||||
cancel: (f32, f32, f32),
|
||||
back: (f32, f32, f32),
|
||||
next: (f32, f32, f32),
|
||||
}
|
||||
fn layout(w: f32, h: f32) -> Layout {
|
||||
let top = (h - BAND).floor();
|
||||
let cx = (w / 2.0).floor();
|
||||
let by = top + 58.0;
|
||||
Layout {
|
||||
top,
|
||||
slider: (cx - 100.0, top + 32.0, 200.0),
|
||||
cancel: (cx - 152.0, by, 98.0),
|
||||
back: (cx - 49.0, by, 98.0),
|
||||
next: (cx + 54.0, by, 98.0),
|
||||
}
|
||||
}
|
||||
fn over(cursor: (f32, f32), (x, y, w): (f32, f32, f32)) -> bool {
|
||||
ui::hovered(cursor, x, y, w, 20.0)
|
||||
}
|
||||
|
||||
impl Calibration {
|
||||
pub fn new(s: &Settings) -> Self {
|
||||
let g = &s.horizon;
|
||||
Self {
|
||||
step: 0,
|
||||
values: [
|
||||
g.hdr_black_level as f32 / 100.0,
|
||||
g.peak_nits() as f32,
|
||||
g.paper_white_nits() as f32,
|
||||
],
|
||||
auto: [
|
||||
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
|
||||
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
|
||||
],
|
||||
dragging: false,
|
||||
}
|
||||
}
|
||||
fn current(&self) -> Step {
|
||||
STEPS[self.step]
|
||||
}
|
||||
fn set(&mut self, v: f32) {
|
||||
let (min, max, step) = self.current().range();
|
||||
self.values[self.step] = (min + ((v - min) / step).round() * step).clamp(min, max);
|
||||
}
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
Text::literal(match self.current() {
|
||||
Step::Black => format!("Black Level: {v:.2} nits"),
|
||||
step => format!("{}: {v:.0} nits", step.name()),
|
||||
})
|
||||
}
|
||||
/// The step, its nits and where the controls start (a share of the height), for
|
||||
/// HDR output's test picture.
|
||||
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
|
||||
(
|
||||
self.current(),
|
||||
self.values[self.step],
|
||||
layout(0.0, h).top / h.max(1.0),
|
||||
)
|
||||
}
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let l = layout(w, h);
|
||||
// Above the band HDR output draws the test picture over whatever is here.
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
|
||||
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
|
||||
let step = self.current();
|
||||
let title = Text::literal(format!(
|
||||
"HDR Calibration {}/{}: {}",
|
||||
self.step + 1,
|
||||
STEPS.len(),
|
||||
step.name()
|
||||
));
|
||||
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
|
||||
let hint = Text::literal(step.hint());
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&hint,
|
||||
0.0,
|
||||
l.top + 18.0,
|
||||
w,
|
||||
rgba(160, 160, 160, 255),
|
||||
);
|
||||
let (min, max, _) = step.range();
|
||||
let (x, y, sw) = l.slider;
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
sw,
|
||||
&self.label(),
|
||||
(self.values[self.step] - min) / (max - min),
|
||||
over(cursor, l.slider) || self.dragging,
|
||||
true,
|
||||
self.dragging,
|
||||
);
|
||||
let last = self.step + 1 == STEPS.len();
|
||||
for (rect, label, active) in [
|
||||
(l.cancel, "Cancel", true),
|
||||
(l.back, "Back", self.step > 0),
|
||||
(l.next, if last { "Save" } else { "Next" }, true),
|
||||
] {
|
||||
let hover = over(cursor, rect) && active;
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
rect.0,
|
||||
rect.1,
|
||||
rect.2,
|
||||
20.0,
|
||||
&Text::literal(label),
|
||||
active,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A press, or a drag with `drag`, at `cursor`.
|
||||
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
|
||||
let l = layout(w, h);
|
||||
let (x, _, sw) = l.slider;
|
||||
if drag {
|
||||
if !self.dragging {
|
||||
return Outcome::Nothing;
|
||||
}
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.slider) {
|
||||
self.dragging = true;
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.cancel) {
|
||||
return Outcome::Cancel;
|
||||
}
|
||||
if over(cursor, l.back) && self.step > 0 {
|
||||
self.step -= 1;
|
||||
return Outcome::Click;
|
||||
}
|
||||
if over(cursor, l.next) {
|
||||
return self.next();
|
||||
}
|
||||
Outcome::Nothing
|
||||
}
|
||||
fn next(&mut self) -> Outcome {
|
||||
if self.step + 1 == STEPS.len() {
|
||||
return Outcome::Save;
|
||||
}
|
||||
self.step += 1;
|
||||
Outcome::Click
|
||||
}
|
||||
/// The slider let go: true if it was held (it clicks on release, as sliders do).
|
||||
pub fn release(&mut self) -> bool {
|
||||
std::mem::take(&mut self.dragging)
|
||||
}
|
||||
/// Left and Right step the slider, Enter goes on, Escape cancels.
|
||||
pub fn key(&mut self, key: KeyCode) -> Outcome {
|
||||
let (_, _, step) = self.current().range();
|
||||
match key {
|
||||
KeyCode::ArrowLeft | KeyCode::ArrowDown => {
|
||||
self.set(self.values[self.step] - step);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::ArrowRight | KeyCode::ArrowUp => {
|
||||
self.set(self.values[self.step] + step);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => self.next(),
|
||||
KeyCode::Escape => Outcome::Cancel,
|
||||
_ => Outcome::Nothing,
|
||||
}
|
||||
}
|
||||
/// Writes what the player landed on into Horizon's settings.
|
||||
pub fn save(&self, s: &mut Settings) {
|
||||
let g = &mut s.horizon;
|
||||
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
|
||||
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
|
||||
Some(auto) if auto == self.values[i] => 0,
|
||||
_ => self.values[i].round() as u32,
|
||||
});
|
||||
g.hdr_peak = peak;
|
||||
g.hdr_paper_white = paper_white;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn calibration_saves_only_on_save() {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr_black_level = 5;
|
||||
let before = s.horizon.clone();
|
||||
let mut c = Calibration::new(&s);
|
||||
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
|
||||
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
|
||||
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
|
||||
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
|
||||
assert_eq!(s.horizon, before);
|
||||
// Black, then Peak (one step up), then Paper White, then Save.
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
|
||||
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
|
||||
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 3);
|
||||
assert_eq!(s.horizon.hdr_peak, before.peak_nits() + 50);
|
||||
assert_eq!(s.horizon.hdr_paper_white, before.hdr_paper_white);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
pub mod graphics_ui;
|
||||
pub mod hdr_calibration;
|
||||
pub mod hud;
|
||||
pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
|
||||
@@ -1178,6 +1178,8 @@ pub enum Page {
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
GraphicsHdr,
|
||||
/// HDR calibration, full screen (`calibration`).
|
||||
HdrCalibration,
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
@@ -1356,6 +1358,10 @@ pub struct OptionsUi {
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
/// HDR calibration while its page is open.
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
|
||||
pub calibration_fullscreen: Option<bool>,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
}
|
||||
@@ -1391,6 +1397,8 @@ impl Default for OptionsUi {
|
||||
in_world: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
agent_status: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1602,7 +1610,9 @@ impl OptionsUi {
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
|
||||
"options.videoTitle"
|
||||
}
|
||||
Page::Controller => "horizon.pages.controller",
|
||||
Page::PadBinds => "horizon.pages.controller_binds",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
@@ -2611,6 +2621,12 @@ impl OptionsUi {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &self.calibration
|
||||
{
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
self.graphics.binding = self.selected_key;
|
||||
self.graphics
|
||||
@@ -2870,6 +2886,15 @@ impl OptionsUi {
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &mut self.calibration
|
||||
{
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
let outcome = c.click((x, y), size, drag);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
use crate::graphics_ui::Action;
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
@@ -2893,6 +2918,10 @@ impl OptionsUi {
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::BindKey(name) => self.selected_key = Some(name),
|
||||
Action::Calibrate => {
|
||||
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
|
||||
self.push(Page::HdrCalibration);
|
||||
}
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -3119,7 +3148,8 @@ impl OptionsUi {
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
if slider || graphics_slider {
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -3277,6 +3307,37 @@ impl OptionsUi {
|
||||
self.pack_screen.as_mut().expect("made above")
|
||||
}
|
||||
/// Escape or Done: leave the current page, applying page results.
|
||||
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
|
||||
/// set before; both go back to Graphics: HDR.
|
||||
fn calibration_outcome(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
outcome: crate::hdr_calibration::Outcome,
|
||||
) -> Vec<Event> {
|
||||
use crate::hdr_calibration::Outcome;
|
||||
match outcome {
|
||||
Outcome::Nothing | Outcome::Changed => Vec::new(),
|
||||
Outcome::Click => vec![Event::ButtonClick],
|
||||
Outcome::Save | Outcome::Cancel => {
|
||||
if let Some(c) = self.calibration.take()
|
||||
&& outcome == Outcome::Save
|
||||
{
|
||||
c.save(s);
|
||||
}
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
events.extend(self.back(s));
|
||||
events
|
||||
}
|
||||
}
|
||||
}
|
||||
/// HDR calibration's test picture, while its page is open: the step, its nits and
|
||||
/// where the controls start (a share of the height `h`).
|
||||
pub fn calibration_picture(&self, h: f32) -> Option<(crate::hdr_calibration::Step, f32, f32)> {
|
||||
self.calibration
|
||||
.as_ref()
|
||||
.filter(|_| self.page() == Page::HdrCalibration)
|
||||
.map(|c| c.picture(h))
|
||||
}
|
||||
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
let page = self.stack.pop().unwrap_or(Page::Main);
|
||||
@@ -3408,6 +3469,12 @@ impl OptionsUi {
|
||||
text: Option<&str>,
|
||||
) -> Vec<Event> {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &mut self.calibration
|
||||
{
|
||||
let outcome = c.key(key);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.resource_error.is_some() {
|
||||
if matches!(key, K::Escape | K::Enter | K::NumpadEnter) {
|
||||
self.resource_error = None;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! outline, nether portal swirl). Positions are clip-space quads; textures
|
||||
//! come from the shared 2D atlas and may repeat.
|
||||
use crate::{
|
||||
effects::EffectInstance,
|
||||
hud::{Rect, TextureFlags},
|
||||
hud_layout::GuiDraw,
|
||||
};
|
||||
@@ -271,6 +272,28 @@ impl ScreenEffects {
|
||||
}
|
||||
}
|
||||
|
||||
/// The nausea effect's blend-out advance: over its last `NAUSEA_BLEND_OUT`
|
||||
/// ticks it has no effect (`MobEffects.NAUSEA`, `endsWithin(60)`).
|
||||
const NAUSEA_BLEND_OUT: i32 = 60;
|
||||
|
||||
/// `MobEffectInstance.BlendState.hasEffect`: still past the blend-out point,
|
||||
/// so the effect counts as on (`!endsWithin(60)` for `MobEffects.NAUSEA`).
|
||||
pub fn nausea_has_effect(e: &EffectInstance) -> bool {
|
||||
!e.ends_within(NAUSEA_BLEND_OUT)
|
||||
}
|
||||
|
||||
/// A new nausea instance's `BlendState`: from nothing, or, when the server
|
||||
/// asked for no blending, at once (`MobEffectInstance.skipBlending`'s
|
||||
/// `BlendState.setImmediate`).
|
||||
pub fn nausea_blend_added(e: &EffectInstance, blend: bool) -> (f32, f32) {
|
||||
let factor = if blend || !nausea_has_effect(e) {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
(factor, factor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -196,5 +196,8 @@
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads that generate, light and mesh chunks. Next start.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph."
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.loading.opening_world": "Opening world",
|
||||
"horizon.loading.cancelling_world": "Cancelling world"
|
||||
}
|
||||
@@ -11,5 +11,8 @@
|
||||
"horizon.options.look_deadzone": "Zona muerta de vista",
|
||||
"horizon.options.button_glyphs": "Símbolos de botones",
|
||||
"horizon.options.menu_navigation": "Navegación de menús",
|
||||
"horizon.options.on_screen_keyboard": "Teclado en pantalla"
|
||||
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
|
||||
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
|
||||
"horizon.loading.opening_world": "Abriendo el mundo",
|
||||
"horizon.loading.cancelling_world": "Cancelando"
|
||||
}
|
||||
@@ -11,5 +11,8 @@
|
||||
"horizon.options.look_deadzone": "Martwa strefa rozglądania",
|
||||
"horizon.options.button_glyphs": "Symbole przycisków",
|
||||
"horizon.options.menu_navigation": "Nawigacja po menu",
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa"
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie"
|
||||
}
|
||||
@@ -195,5 +195,8 @@
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Потоки, которые генерируют, освещают и строят чанки. Со следующего запуска.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, при сбое — DirectX 12 (Windows) или OpenGL (Linux). Со следующего запуска.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком."
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.loading.opening_world": "Открытие мира",
|
||||
"horizon.loading.cancelling_world": "Отмена"
|
||||
}
|
||||
@@ -11,5 +11,8 @@
|
||||
"horizon.options.look_deadzone": "Мертва зона огляду",
|
||||
"horizon.options.button_glyphs": "Значки кнопок",
|
||||
"horizon.options.menu_navigation": "Навігація в меню",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура"
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування"
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"horizon.options.controller": "手柄",
|
||||
"horizon.options.vibration": "振动",
|
||||
"horizon.options.adaptive_triggers": "自适应扳机",
|
||||
"horizon.options.lightbar": "光条",
|
||||
"horizon.options.look_sensitivity": "视角灵敏度",
|
||||
"horizon.options.invert_x": "反转视角 X 轴",
|
||||
"horizon.options.invert_y": "反转视角 Y 轴",
|
||||
"horizon.options.sneak_button": "潜行按钮",
|
||||
"horizon.options.move_deadzone": "移动摇杆死区",
|
||||
"horizon.options.look_deadzone": "视角摇杆死区",
|
||||
"horizon.options.button_glyphs": "按键图标",
|
||||
"horizon.options.menu_navigation": "菜单导航",
|
||||
"horizon.options.on_screen_keyboard": "虚拟键盘",
|
||||
"horizon.options.chat_delay": "聊天延迟",
|
||||
"horizon.options.lightbar.health": "生命值",
|
||||
"horizon.options.lightbar.dimension": "维度",
|
||||
"horizon.options.glyphs.auto": "自动",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "焦点",
|
||||
"horizon.options.menu_pointer": "指针",
|
||||
"horizon.options.keyboard.auto": "自动",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "此功能在 Horizon 中尚不可用。",
|
||||
"horizon.options.search": "搜索设置…",
|
||||
"horizon.pages.mods": "模组",
|
||||
"horizon.pages.shader_packs": "光影包",
|
||||
"horizon.pages.controller": "手柄设置",
|
||||
"horizon.pages.controller_binds": "手柄按键绑定",
|
||||
"horizon.pages.extras": "Horizon 附加功能",
|
||||
"horizon.pages.settings": "Horizon 设置",
|
||||
"horizon.pages.agent": "AI 代理",
|
||||
"horizon.pages.settings.tooltip": "Horizon 自带的设置:图形、手柄、附加功能和模组。",
|
||||
"horizon.pages.graphics.tooltip": "简单的图形设置:每一项都标明了它的开销。",
|
||||
"horizon.pages.graphics_advanced.tooltip": "每一项图形设置,包括 Horizon 自带的,并标明各自的开销。",
|
||||
"horizon.pages.controller.tooltip": "手柄:按键、摇杆、振动和菜单导航。",
|
||||
"horizon.pages.extras.tooltip": "可选的附加功能,默认全部关闭:不开启时游戏保持原版行为。",
|
||||
"horizon.pages.agent.tooltip": "让你的 AI 代理来玩:本机上的 horizon-ctl 和 MCP 客户端。",
|
||||
"horizon.pages.shader_packs.tooltip": "来自 shaderpacks 文件夹的光影包:Horizon 自带格式与 shaders/ 格式。同一时间只运行一个。",
|
||||
"horizon.pages.mods.tooltip": "为 Horizon 制作的模组,来自 horizon-mods 文件夹。每个模组都在沙盒中运行,开启前保持关闭。",
|
||||
"horizon.pages.open.graphics": "图形…",
|
||||
"horizon.pages.open.graphics_advanced": "高级图形…",
|
||||
"horizon.pages.open.shader_packs": "光影包…",
|
||||
"horizon.pages.open.controller": "手柄设置…",
|
||||
"horizon.pages.open.extras": "附加功能…",
|
||||
"horizon.pages.open.mods": "模组…",
|
||||
"horizon.pages.open.agent": "AI 代理…",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon 图形…",
|
||||
"horizon.pages.open.controller_binds": "手柄按键绑定…",
|
||||
"horizon.pages.open.horizon_settings": "Horizon 设置…",
|
||||
"horizon.mods.empty": "没有模组:请将 .hmod 文件放入 horizon-mods 文件夹",
|
||||
"horizon.mods.stopped": "(已停止)",
|
||||
"horizon.mods.not_built": "(未构建)",
|
||||
"horizon.mods.dev": "(开发版)",
|
||||
"horizon.mods.settings": "设置",
|
||||
"horizon.mods.open_folder": "打开模组文件夹",
|
||||
"horizon.shader_packs.empty": "没有光影包:请将其放入 shaderpacks 文件夹",
|
||||
"horizon.shader_packs.kind_horizon": "(Horizon)",
|
||||
"horizon.agent.copy_details": "复制连接详情",
|
||||
"horizon.multiplayer.join": "加入",
|
||||
"horizon.multiplayer.cancel_reconnecting": "取消重新连接",
|
||||
"horizon.multiplayer.join_friend": "加入好友",
|
||||
"horizon.multiplayer.invite_code": "邀请码",
|
||||
"horizon.multiplayer.relay": "中继(可选)",
|
||||
"Preset": "预设",
|
||||
"LOD Distance": "LOD 距离",
|
||||
"Far Detail": "远景细节",
|
||||
"Cave Culling": "洞穴剔除",
|
||||
"Entity Culling": "实体剔除",
|
||||
"Chunk Updates": "区块更新",
|
||||
"Upload Budget": "上传预算",
|
||||
"Sky": "天空",
|
||||
"Fog": "雾",
|
||||
"Weather": "天气",
|
||||
"Animated Textures": "动画纹理",
|
||||
"Present Mode": "呈现模式",
|
||||
"Frame Pacing": "帧节奏",
|
||||
"Worker Threads": "工作线程",
|
||||
"Resolution Scale": "渲染比例",
|
||||
"Anti-aliasing": "抗锯齿",
|
||||
"Upscaling": "放大",
|
||||
"HDR Settings...": "HDR 设置…",
|
||||
"Paper White": "纸白亮度",
|
||||
"Peak Brightness": "峰值亮度",
|
||||
"Black Level": "黑位",
|
||||
"UI Brightness": "界面亮度",
|
||||
"Battery Mode": "电池模式",
|
||||
"Graphics API": "图形 API",
|
||||
"Performance Overlay": "性能叠加层",
|
||||
"Full Detail Distance": "全细节距离",
|
||||
"On": "开",
|
||||
"Off": "关",
|
||||
"Unlimited": "无限制",
|
||||
"Auto": "自动",
|
||||
"Custom": "自定义",
|
||||
"Performance": "性能",
|
||||
"Balanced": "均衡",
|
||||
"Quality": "质量",
|
||||
"Ultra": "超高",
|
||||
"Ultra Performance": "极致性能",
|
||||
"Native": "原生",
|
||||
"Full": "完全",
|
||||
"Reduced": "精简",
|
||||
"Efficient": "高效",
|
||||
"Precise": "精确",
|
||||
"Auto (VSync)": "自动(垂直同步)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "邮箱",
|
||||
"Immediate": "立即",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS 与图表",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "无开销",
|
||||
"Low cost": "低开销",
|
||||
"Medium cost": "中等开销",
|
||||
"High cost": "高开销",
|
||||
"Saves time": "节省时间",
|
||||
"Rendering": "渲染",
|
||||
"Display": "显示",
|
||||
"Preferences": "偏好",
|
||||
"Overlay": "叠加层",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "图形",
|
||||
"Graphics: Advanced": "图形:高级",
|
||||
"Graphics: HDR": "图形:HDR",
|
||||
"Advanced...": "高级…",
|
||||
"Simple...": "简单…",
|
||||
"Done": "完成",
|
||||
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
|
||||
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
|
||||
"Turn HDR on to use it.": "开启 HDR 后才能使用。",
|
||||
"%s chunks": "%s 个区块",
|
||||
"%s per frame": "每帧 %s",
|
||||
"%s MiB/frame": "每帧 %s MiB",
|
||||
"%s KiB/frame": "每帧 %s KiB",
|
||||
"%s nits": "%s 尼特",
|
||||
"Auto (%s)": "自动(%s)",
|
||||
"Auto (%s nits)": "自动(%s 尼特)",
|
||||
"%.2f nits": "%.2f 尼特",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "完整细节的地形范围。LOD 距离可以让本地世界延伸得更远。",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "本地世界的远景粗略地形。集成显卡最多 128 个区块。",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "“精简”把远景画得更粗略:在这块集成显卡上更快。",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "“精简”把远景画得更粗略,供带不动它的显卡使用。",
|
||||
"Field of view. Wider shows more terrain at once.": "视野。越宽同一时间看到的地形越多。",
|
||||
"Caps frames per second; a cap saves power and heat.": "限制每秒帧数;限制帧率可以省电降温。",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "等待显示器的刷新:无画面撕裂,略有延迟。",
|
||||
"Size of menus and the HUD.": "菜单与 HUD 的大小。",
|
||||
"Sets the options below together. Changing one makes it Custom.": "一起设置下面的选项。单独改动其中一项会变成“自定义”。",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "熔岩、火与阳光比白色更亮。需要 HDR 显示器。",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "显示器最深的黑色。OLED 为 0;液晶屏会微微发灰。",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "高光能有多亮。“自动”使用系统报告的峰值。",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "白纸与菜单的亮度。“自动”使用你桌面的白色。",
|
||||
"How bright the HUD and menus are, against paper white.": "HUD 和菜单相对纸白的亮度。",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "隐藏不透明方块后面的模型。实体仍会更新。自定义资源包时应关闭。",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
|
||||
"Blends grass and water colours across biomes when building chunks.": "构建区块时在群系之间平滑过渡草地与水的颜色。",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "云层:关闭、平面(快速)或 3D(高品质)。",
|
||||
"How far clouds reach, in chunks.": "云能延伸多远,以区块计。",
|
||||
"Draws the sky, sun, moon and stars.": "绘制天空、太阳、月亮和星星。",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "距离雾。关闭后能看到已加载世界的边缘。",
|
||||
"Draws rain and snow.": "绘制雨和雪。",
|
||||
"How far rain and snow fall around you.": "你周围雨雪落下的范围。",
|
||||
"How many particles effects spawn.": "效果生成的粒子数量。",
|
||||
"Round shadows under mobs and items.": "生物和物品脚下的圆形阴影。",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "树叶带透光空隙。关闭后画成实心。",
|
||||
"Sorts water and glass more exactly behind each other.": "更精确地排序互相重叠的水与玻璃。",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "哪些区块重建要等这一帧完成:无、附近的,或你身边的。",
|
||||
"Animates water, lava, fire and other moving textures.": "让水、熔岩、火等动态纹理动起来。",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "每个屏幕像素对应的画面像素。低于 100% 时 GPU 更省力。",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "平滑锯齿边缘。FXAA 更柔和;TAA 最平滑,但移动物体可能拖影。",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "以更小的尺寸渲染世界再用 TAA 重建。更快,但更模糊。",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "在 HDR 显示器上输出 HDR:熔岩、火与阳光更亮。",
|
||||
"How frames reach the screen while VSync is off.": "关闭垂直同步时帧画面如何送到屏幕。",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "“高效”会休眠到下一帧;“精确”持续空转,与原版相同。",
|
||||
"Fills the screen (borderless).": "填满整个屏幕(无边框)。",
|
||||
"Brightens dark places.": "提亮黑暗的地方。",
|
||||
"Lowers the frame rate when minimised, or idle too.": "最小化或空闲时降低帧率。",
|
||||
"Sways the view while walking.": "行走时晃动视角。",
|
||||
"Fades newly loaded chunks in.": "新加载的区块淡入显示。",
|
||||
"Darkens the screen's edges.": "加深屏幕边缘的暗角。",
|
||||
"Shows when the world saves.": "显示世界保存的时机。",
|
||||
"Shows attack cooldown.": "显示攻击冷却。",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "限制 60 FPS(菜单或失焦时 30),工作线程减半:更省电。",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "负责生成、光照与网格化区块的线程数。下次启动生效。",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "优先 Vulkan,失败时用 DirectX 12(Windows)或 OpenGL(Linux)。下次启动生效。",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
"horizon.loading.opening_world": "正在打开世界",
|
||||
"horizon.loading.cancelling_world": "正在取消"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The stacks passed to item models for an entity's two arms.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
mobs::{Extra, render},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let case: Value = serde_json::from_str(&line?)?;
|
||||
let mut extra = Extra::default();
|
||||
let main = case["main"].as_str().ok_or("main")?;
|
||||
if main != "air" {
|
||||
extra.set_main_hand_stack(Some(ItemStack::new(&format!("minecraft:{main}"), 1)));
|
||||
}
|
||||
let off = case["off"].as_str().ok_or("off")?;
|
||||
if off != "air" {
|
||||
extra.off_hand = Some(format!("minecraft:{off}").into());
|
||||
}
|
||||
let left = case["left"].as_bool().ok_or("left")?;
|
||||
for physical_left in [false, true] {
|
||||
let stack = render::held_item_stack(&extra, physical_left != left);
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
stack.as_ref().map_or("minecraft:air", |s| &s.item)
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -48,6 +48,7 @@ mod frame_limiter_probe;
|
||||
mod game_loop_probe;
|
||||
mod ghost_recipe_fixture;
|
||||
mod held_drop_fixture;
|
||||
mod held_item_stack_probe;
|
||||
mod hotbar_fixture;
|
||||
mod hud_fixture;
|
||||
mod input_probe;
|
||||
@@ -145,6 +146,7 @@ probes! {
|
||||
game_loop_probe,
|
||||
ghost_recipe_fixture,
|
||||
held_drop_fixture,
|
||||
held_item_stack_probe,
|
||||
hotbar_fixture,
|
||||
hud_fixture,
|
||||
input_probe,
|
||||
|
||||
@@ -529,8 +529,12 @@ impl HiveHost for ProbeHive<'_, '_> {
|
||||
}
|
||||
fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<Mob> {
|
||||
let index = self.first + self.added.len();
|
||||
let mut m =
|
||||
horizon_client::entity_nbt::mob_from_nbt(tag, self.types, 100_000 + index as u32)?;
|
||||
let mut m = horizon_client::entity_nbt::mob_from_nbt(
|
||||
tag,
|
||||
self.types,
|
||||
100_000 + index as u32,
|
||||
None,
|
||||
)?;
|
||||
if m.species() != Species::Bee {
|
||||
return None;
|
||||
}
|
||||
@@ -1180,6 +1184,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
@@ -1426,7 +1431,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None);
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None, None);
|
||||
let flower = horizon_client::mobs::bee::get(&mobs[i]).and_then(|b| b.flower);
|
||||
let mut honey = Vec::new();
|
||||
let stored = {
|
||||
|
||||
@@ -353,8 +353,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("id".into(), Tag::String(id.into()));
|
||||
}
|
||||
let mob = entity_nbt::mob_from_nbt(&tag, &types, 1).ok_or("unreadable villager")?;
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None);
|
||||
let mob =
|
||||
entity_nbt::mob_from_nbt(&tag, &types, 1, None).ok_or("unreadable villager")?;
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None);
|
||||
writeln!(out, "{name} snbt {text}")?;
|
||||
let mut kept = horizon_world::nbt::Compound::new();
|
||||
for key in [
|
||||
|
||||
@@ -35,9 +35,19 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
|
||||
}
|
||||
|
||||
/// `EntityTypes.OP_ONLY_CUSTOM_DATA`: the entity types whose data only a player
|
||||
/// who can use game master blocks sets (`EntityType.onlyOpCanSetNbt`).
|
||||
fn egg_op_only(id: &str) -> bool {
|
||||
matches!(
|
||||
id,
|
||||
"minecraft:falling_block"
|
||||
| "minecraft:command_block_minecart"
|
||||
| "minecraft:spawner_minecart"
|
||||
)
|
||||
}
|
||||
|
||||
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
|
||||
/// permission: a spawn egg carries data of an op-only entity type
|
||||
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
|
||||
/// permission: a spawn egg carries data of an op-only entity type.
|
||||
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
stack.item.ends_with("_spawn_egg")
|
||||
&& matches!(
|
||||
@@ -45,12 +55,7 @@ pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:entity_data"))
|
||||
.and_then(|data| data.get("id")),
|
||||
Some(Tag::String(id)) if matches!(
|
||||
id.to_string_lossy().as_str(),
|
||||
"minecraft:falling_block"
|
||||
| "minecraft:command_block_minecart"
|
||||
| "minecraft:spawner_minecart"
|
||||
)
|
||||
Some(Tag::String(id)) if egg_op_only(&id.to_string_lossy())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,6 +81,24 @@ pub fn spawn_egg_on_spawner(
|
||||
(id, data.filter(|_| op).cloned())
|
||||
}
|
||||
|
||||
/// `EntityType.updateCustomEntityTag`: the `minecraft:entity_data` a spawn egg
|
||||
/// puts on the mob it spawned — only when the data names the spawned type (data
|
||||
/// without an `id` is the egg's own), and an op-only type's data only for an op.
|
||||
pub fn spawn_egg_entity_data<'a>(
|
||||
stack: &'a crate::items::ItemStack,
|
||||
spawned: &str,
|
||||
op: bool,
|
||||
) -> Option<&'a Tag> {
|
||||
let data = stack.data()?.other("minecraft:entity_data")?;
|
||||
if matches!(data.get("id"), Some(Tag::String(id)) if id.to_string_lossy() != spawned) {
|
||||
return None;
|
||||
}
|
||||
if egg_op_only(spawned) && !op {
|
||||
return None;
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
@@ -517,6 +540,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spawn_egg_data_needs_its_own_type_and_an_op_for_an_op_only_one() {
|
||||
let egg = |entity_data: &str| {
|
||||
let components = snbt(&format!("{{\"minecraft:entity_data\":{entity_data}}}"));
|
||||
let (damage, extra) = crate::item_data::decode_components(Some(&components));
|
||||
crate::items::ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..crate::items::ItemStack::new("minecraft:zombie_spawn_egg", 1)
|
||||
}
|
||||
};
|
||||
// `entity_data` without an id names the egg's own type: applied for anyone.
|
||||
let own = egg("{NoAI:1b}");
|
||||
assert_eq!(
|
||||
spawn_egg_entity_data(&own, "minecraft:zombie", false)
|
||||
.and_then(|data| data.get("NoAI")),
|
||||
Some(&Tag::Byte(1))
|
||||
);
|
||||
// `entity_data.id` names another type: the spawned type keeps no data.
|
||||
let other = egg("{id:\"minecraft:pig\",NoAI:1b}");
|
||||
assert_eq!(
|
||||
spawn_egg_entity_data(&other, "minecraft:zombie", false),
|
||||
None
|
||||
);
|
||||
// An op-only type's data needs an op.
|
||||
assert_eq!(
|
||||
spawn_egg_entity_data(&own, "minecraft:falling_block", false),
|
||||
None
|
||||
);
|
||||
assert!(spawn_egg_entity_data(&own, "minecraft:falling_block", true).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_data_names_its_spawn_and_op_only_types() {
|
||||
let item = |text: &str| {
|
||||
|
||||
@@ -25,32 +25,47 @@ static PENDING: Mutex<Pending> = Mutex::new(Pending {
|
||||
save_failure: None,
|
||||
});
|
||||
|
||||
impl Pending {
|
||||
/// `reportChunkLoadFailure` after `warnOnLowDiskSpace` found `low_disk_space`.
|
||||
fn load_failed(&mut self, x: i32, z: i32, low_disk_space: bool) {
|
||||
self.low_disk_space |= low_disk_space;
|
||||
self.load_failure = Some((x, z));
|
||||
}
|
||||
|
||||
/// `reportChunkSaveFailure` after `warnOnLowDiskSpace` found `low_disk_space`.
|
||||
fn save_failed(&mut self, x: i32, z: i32, low_disk_space: bool) {
|
||||
self.low_disk_space |= low_disk_space;
|
||||
self.save_failure = Some((x, z));
|
||||
}
|
||||
}
|
||||
|
||||
fn with(update: impl FnOnce(&mut Pending)) {
|
||||
let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
update(&mut pending);
|
||||
}
|
||||
|
||||
/// `MinecraftServer.warnOnLowDiskSpace` for the world folder `dir`.
|
||||
fn warn_on_low_disk_space(dir: &Path) {
|
||||
if save::low_disk_space(dir) {
|
||||
fn warn_on_low_disk_space(dir: &Path) -> bool {
|
||||
let low = save::low_disk_space(dir);
|
||||
if low {
|
||||
// `MinecraftServer.sendLowDiskSpaceWarning`.
|
||||
log::warn!("Low disk space! Might not be able to save the world.");
|
||||
with(|p| p.low_disk_space = true);
|
||||
}
|
||||
low
|
||||
}
|
||||
|
||||
/// `reportChunkLoadFailure` for the chunk at (`x`, `z`) of the dimension in `dir`.
|
||||
pub fn report_load_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
|
||||
log::error!("Failed to load chunk {x},{z}: {error}");
|
||||
warn_on_low_disk_space(dir);
|
||||
with(|p| p.load_failure = Some((x, z)));
|
||||
let low = warn_on_low_disk_space(dir);
|
||||
with(|p| p.load_failed(x, z, low));
|
||||
}
|
||||
|
||||
/// `reportChunkSaveFailure`.
|
||||
pub fn report_save_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
|
||||
log::error!("Failed to save chunk {x},{z}: {error}");
|
||||
warn_on_low_disk_space(dir);
|
||||
with(|p| p.save_failure = Some((x, z)));
|
||||
let low = warn_on_low_disk_space(dir);
|
||||
with(|p| p.save_failed(x, z, low));
|
||||
}
|
||||
|
||||
/// `Minecraft.sendLowDiskSpaceWarning`: a server's `ClientboundLowDiskSpaceWarningPacket`
|
||||
@@ -103,28 +118,40 @@ pub fn save_failure_toast(x: i32, z: i32) -> SystemToast {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// These tests keep off `PENDING` where they can: the save tests run in
|
||||
// the same process and report their own chunk failures into it (#4854).
|
||||
#[test]
|
||||
fn failures_keep_the_newest_chunk_of_each_kind() {
|
||||
let _ = take();
|
||||
let missing = Path::new("/nonexistent-horizon-world");
|
||||
report_load_failure(missing, 1, 2, &"a");
|
||||
report_load_failure(missing, 3, 4, &"b");
|
||||
report_save_failure(missing, 5, 6, &"c");
|
||||
// A folder that cannot be asked has no low-space warning.
|
||||
let mut pending = Pending::default();
|
||||
pending.load_failed(1, 2, false);
|
||||
pending.load_failed(3, 4, false);
|
||||
pending.save_failed(5, 6, false);
|
||||
assert_eq!(
|
||||
take(),
|
||||
pending,
|
||||
Pending {
|
||||
low_disk_space: false,
|
||||
load_failure: Some((3, 4)),
|
||||
save_failure: Some((5, 6)),
|
||||
}
|
||||
);
|
||||
assert_eq!(take(), Pending::default());
|
||||
// A later failure on a full disk raises the warning, and one with
|
||||
// space left does not lower it.
|
||||
pending.save_failed(7, 8, true);
|
||||
pending.load_failed(9, 10, false);
|
||||
assert_eq!(
|
||||
pending,
|
||||
Pending {
|
||||
low_disk_space: true,
|
||||
load_failure: Some((9, 10)),
|
||||
save_failure: Some((7, 8)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The only test that drains `PENDING`; other tests only add to it, so
|
||||
/// the flag it raises is still there when it takes it.
|
||||
#[test]
|
||||
fn a_servers_warning_packet_toasts_low_disk_space() {
|
||||
let _ = take();
|
||||
report_low_disk_space();
|
||||
assert!(take().low_disk_space);
|
||||
}
|
||||
|
||||
@@ -85,8 +85,9 @@ impl Placement {
|
||||
self.slots.is_empty()
|
||||
}
|
||||
}
|
||||
/// A recipe result: `ItemStackTemplate` (`id`, `count`, `components`), with the
|
||||
/// id optional for the transmuting recipes that keep the input's item.
|
||||
/// A recipe result: `ItemStackTemplate` (`id`, `count`, `components`), or a bare item id
|
||||
/// (`ItemStackTemplate.CODEC` falls back to `Item.CODEC`), with the id optional for the
|
||||
/// transmuting recipes that keep the input's item.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Template {
|
||||
pub item: Option<Arc<str>>,
|
||||
@@ -113,7 +114,7 @@ impl Template {
|
||||
None => (0, None),
|
||||
};
|
||||
Some(Self {
|
||||
item: v["id"].as_str().map(Arc::from),
|
||||
item: horizon_world::loot::recipe_result_id(v).map(Arc::from),
|
||||
count: template_count(v)?,
|
||||
damage,
|
||||
extra,
|
||||
@@ -1361,7 +1362,7 @@ impl Recipes {
|
||||
}
|
||||
"minecraft:crafting_special_bannerduplicate" => special(
|
||||
ing("banner")
|
||||
.zip(v["result"]["id"].as_str())
|
||||
.zip(horizon_world::loot::recipe_result_id(&v["result"]))
|
||||
.map(|(banner, id)| Special::BannerDuplicate {
|
||||
banner,
|
||||
result: id.to_owned(),
|
||||
@@ -1370,7 +1371,7 @@ impl Recipes {
|
||||
"minecraft:crafting_special_shielddecoration" => special(
|
||||
ing("banner")
|
||||
.zip(ing("target"))
|
||||
.zip(v["result"]["id"].as_str())
|
||||
.zip(horizon_world::loot::recipe_result_id(&v["result"]))
|
||||
.map(|((banner, target), id)| Special::ShieldDecoration {
|
||||
banner,
|
||||
target,
|
||||
@@ -1901,6 +1902,33 @@ mod tests {
|
||||
assert!(result(r#"{"id":"minecraft:iron_ingot","count":0}"#).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn a_recipe_result_can_be_a_bare_item_id() {
|
||||
// `ItemStackTemplate.CODEC` falls back to `Item.CODEC`: `"result": "mod:item"` is one item.
|
||||
let result = |json: &str| recipe_result(&serde_json::from_str(json).unwrap());
|
||||
let stack = result(r#""ruby:ruby""#).unwrap();
|
||||
assert_eq!((&*stack.item, stack.count), ("ruby:ruby", 1));
|
||||
// The `item` key of older versions is no `id`, and a number names no item.
|
||||
assert!(result(r#"{"item":"ruby:ruby"}"#).is_none());
|
||||
assert!(result("42").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn a_shapeless_recipe_makes_the_item_its_bare_result_names() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-bare-result-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/bare.json"),
|
||||
br#"{"type":"minecraft:crafting_shapeless","ingredients":["minecraft:flint"],"result":"minecraft:stick"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let grid = [Some(ItemStack::new("minecraft:flint", 1)), None, None, None];
|
||||
let (_, made) = recipes.find(&grid, 2).unwrap();
|
||||
assert_eq!((&*made.item, made.count), ("minecraft:stick", 1));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn cook_finds_the_first_recipe_the_scan_finds() {
|
||||
let recipes = cooking_book();
|
||||
for kind in [
|
||||
|
||||
@@ -1004,49 +1004,45 @@ impl ArmorStand {
|
||||
Vec3::new(p.x + w, p.y + h, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `isInWater`: some water block of the stand's box has its surface
|
||||
/// (`FluidState.getHeight`, a full block under more water) at or above the
|
||||
/// box's bottom, as `Entity.updateFluidHeightAndDoFluidPushing` counts it.
|
||||
pub fn in_water(&self, world: &World, fluids: &crate::motion::FluidResources) -> bool {
|
||||
let (lo, hi) = (self.bounds().min_position(), self.bounds().max_position());
|
||||
let water = |x: i32, y: i32, z: i32| {
|
||||
let state = fluids.fluid(world.block(x, y, z));
|
||||
(state.kind() == horizon_world::fluid::FluidKind::Water).then_some(state)
|
||||
};
|
||||
(lo.x.floor() as i32..=hi.x.floor() as i32).any(|x| {
|
||||
(lo.y.floor() as i32..hi.y.ceil() as i32).any(|y| {
|
||||
(lo.z.floor() as i32..=hi.z.floor() as i32).any(|z| {
|
||||
water(x, y, z).is_some_and(|state| {
|
||||
let height = if water(x, y + 1, z).is_some() {
|
||||
1.0
|
||||
} else {
|
||||
state.own_height()
|
||||
};
|
||||
f64::from(y) + f64::from(height) >= lo.y
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// LivingEntity.travel (armor stands have no other movement): move by
|
||||
/// the velocity (a landing stops the fall,
|
||||
/// `Block.updateEntityMovementAfterFallOn`), let the blocks it is inside
|
||||
/// LivingEntity.travel (armor stands have no other movement): clamp tiny
|
||||
/// velocity components (`LivingEntity.aiStep`), move by the velocity (a
|
||||
/// landing stops the fall, `Block.updateEntityMovementAfterFallOn`, and a
|
||||
/// wall collision zeros that horizontal axis), let the blocks it is inside
|
||||
/// push it (`push`: a bubble column), then in water the 0.8 slowdown and
|
||||
/// `getFluidFallingAdjustedMovement`, in air gravity 0.08 and the 0.98
|
||||
/// drag, so a stand at rest on the ground keeps -0.0784 as vanilla's.
|
||||
/// `getFluidFallingAdjustedMovement`, in air gravity 0.08, ground/air
|
||||
/// horizontal drag (`friction * 0.91`), and the 0.98 vertical drag, so a
|
||||
/// stand at rest on the ground keeps -0.0784 as vanilla's.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
scene: &mut crate::collisions::SceneCollisions,
|
||||
facts: &BlockFacts,
|
||||
in_water: bool,
|
||||
push: &mut dyn FnMut(Aabb, &mut Vec3),
|
||||
) {
|
||||
if self.marker {
|
||||
return;
|
||||
}
|
||||
let wanted = self.velocity;
|
||||
// `travelInFluid` reads `isFalling` before the move and any push.
|
||||
let v = self.velocity;
|
||||
let wanted = Vec3::new(
|
||||
if v.x.abs() < 0.003 { 0.0 } else { v.x },
|
||||
if v.y.abs() < 0.003 { 0.0 } else { v.y },
|
||||
if v.z.abs() < 0.003 { 0.0 } else { v.z },
|
||||
);
|
||||
self.velocity = wanted;
|
||||
// `travelInFluid` reads `isFalling` before the move and any push;
|
||||
// `travelInAir` reads ground friction before the move.
|
||||
let falling = wanted.y <= 0.0;
|
||||
let mut y = wanted.y;
|
||||
let friction = if !in_water && self.on_ground {
|
||||
let below = scene.world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y - 0.500_001).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
facts.get(below).friction
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mut velocity = wanted;
|
||||
if let Ok(moved) = scene.resolve(self.bounds(), wanted, 0.0, self.on_ground) {
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -1054,26 +1050,37 @@ impl ArmorStand {
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
|
||||
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
|
||||
velocity.x = 0.0;
|
||||
}
|
||||
if moved.y != wanted.y {
|
||||
y = 0.0;
|
||||
velocity.y = 0.0;
|
||||
}
|
||||
if (wanted.z - moved.z).abs() >= f64::from(1.0e-5_f32) {
|
||||
velocity.z = 0.0;
|
||||
}
|
||||
}
|
||||
let mut velocity = Vec3::new(0.0, y, 0.0);
|
||||
push(self.bounds(), &mut velocity);
|
||||
self.velocity = if in_water {
|
||||
// travelInWater: slowDown 0.8, then `getFluidFallingAdjustedMovement`
|
||||
// with gravity / 16 (the stand is falling while its fall is not
|
||||
// already nearly stopped).
|
||||
let y = velocity.y * f64::from(0.8f32);
|
||||
let slow = f64::from(0.8f32);
|
||||
let y = velocity.y * slow;
|
||||
let adjusted =
|
||||
if falling && (y - 0.005).abs() >= 0.003 && (y - 0.08 / 16.0).abs() < 0.003 {
|
||||
-0.003
|
||||
} else {
|
||||
y - 0.08 / 16.0
|
||||
};
|
||||
Vec3::new(0.0, adjusted, 0.0)
|
||||
Vec3::new(velocity.x * slow, adjusted, velocity.z * slow)
|
||||
} else {
|
||||
Vec3::new(0.0, (velocity.y - 0.08) * f64::from(0.98f32), 0.0)
|
||||
let drag = f64::from(friction * 0.91f32);
|
||||
Vec3::new(
|
||||
velocity.x * drag,
|
||||
(velocity.y - 0.08) * f64::from(0.98f32),
|
||||
velocity.z * drag,
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Dropped-item immersion and currents through the shared vanilla fluid helpers.
|
||||
use super::ItemEntity;
|
||||
//! Dropped-item and armor-stand immersion and currents through the shared vanilla fluid helpers.
|
||||
use super::{ArmorStand, ItemEntity};
|
||||
use crate::{
|
||||
motion::{FluidResources, FluidTags, LAVA, WATER},
|
||||
world::World,
|
||||
@@ -18,66 +18,111 @@ use horizon_world::{
|
||||
registry::BlockStateId,
|
||||
};
|
||||
type Error = std::convert::Infallible;
|
||||
struct ItemFluidWorld<'a> {
|
||||
item: &'a mut ItemEntity,
|
||||
struct EntityFluidWorld<'a> {
|
||||
bounds: Aabb,
|
||||
position: Vec3,
|
||||
eye_y: f64,
|
||||
velocity: &'a mut Vec3,
|
||||
world: &'a World,
|
||||
fluids: &'a FluidResources,
|
||||
}
|
||||
fn update_entity_fluids(
|
||||
bounds: Aabb,
|
||||
position: Vec3,
|
||||
eye_y: f64,
|
||||
velocity: &mut Vec3,
|
||||
world: &World,
|
||||
fluids: &FluidResources,
|
||||
) -> (f64, f64) {
|
||||
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
|
||||
let mut scene = EntityFluidWorld {
|
||||
bounds,
|
||||
position,
|
||||
eye_y,
|
||||
velocity,
|
||||
world,
|
||||
fluids,
|
||||
};
|
||||
if interaction.update(&mut scene, false, 4096).is_err() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let water_height = interaction.fluid_height(&scene, WATER);
|
||||
let lava_height = interaction.fluid_height(&scene, LAVA);
|
||||
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
|
||||
let _ = interaction.apply_current(
|
||||
&mut scene,
|
||||
LAVA,
|
||||
if world.fast_lava {
|
||||
0.007
|
||||
} else {
|
||||
0.0023333333333333335
|
||||
},
|
||||
);
|
||||
(water_height, lava_height)
|
||||
}
|
||||
impl ItemEntity {
|
||||
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
|
||||
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
|
||||
let mut scene = ItemFluidWorld {
|
||||
item: self,
|
||||
update_entity_fluids(
|
||||
self.bounds(),
|
||||
self.position,
|
||||
self.position.y + f64::from(0.2125f32),
|
||||
&mut self.velocity,
|
||||
world,
|
||||
fluids,
|
||||
};
|
||||
if interaction.update(&mut scene, false, 4096).is_err() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let water_height = interaction.fluid_height(&scene, WATER);
|
||||
let lava_height = interaction.fluid_height(&scene, LAVA);
|
||||
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
|
||||
let _ = interaction.apply_current(
|
||||
&mut scene,
|
||||
LAVA,
|
||||
if world.fast_lava {
|
||||
0.007
|
||||
} else {
|
||||
0.0023333333333333335
|
||||
},
|
||||
);
|
||||
(water_height, lava_height)
|
||||
)
|
||||
}
|
||||
}
|
||||
impl FluidMembership for ItemFluidWorld<'_> {
|
||||
impl ArmorStand {
|
||||
/// `Entity.updateFluidHeightAndDoFluidPushing`: refreshes water and lava
|
||||
/// immersion over the stand's deflated box and pushes its velocity by any
|
||||
/// water or lava current. Returns whether the stand is in water (`isInWater`).
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
|
||||
if self.marker {
|
||||
return false;
|
||||
}
|
||||
let eye = if self.small {
|
||||
f64::from(0.9875f32 * 0.9)
|
||||
} else {
|
||||
f64::from(1.7775f32)
|
||||
};
|
||||
let (water_height, _) = update_entity_fluids(
|
||||
self.bounds(),
|
||||
self.position,
|
||||
self.position.y + eye,
|
||||
&mut self.velocity,
|
||||
world,
|
||||
fluids,
|
||||
);
|
||||
water_height > 0.0
|
||||
}
|
||||
}
|
||||
impl FluidMembership for EntityFluidWorld<'_> {
|
||||
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
|
||||
FluidTags.fluid_in_tag(kind, tag)
|
||||
}
|
||||
}
|
||||
impl CurrentTarget for ItemFluidWorld<'_> {
|
||||
impl CurrentTarget for EntityFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn is_player_class(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn velocity(&mut self) -> Result<Vec3, Error> {
|
||||
Ok(self.item.velocity)
|
||||
Ok(*self.velocity)
|
||||
}
|
||||
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> {
|
||||
let v = self.item.velocity;
|
||||
let v = *self.velocity;
|
||||
let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z);
|
||||
if delta.is_finite() && sum.is_finite() {
|
||||
self.item.velocity = sum;
|
||||
*self.velocity = sum;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
impl FluidInteractionWorld for EntityFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Chunk = ();
|
||||
fn interaction_box(&mut self) -> Result<Option<Aabb>, Error> {
|
||||
Ok(Some(
|
||||
self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
|
||||
))
|
||||
Ok(Some(self.bounds.deflate(Vec3::new(0.001, 0.001, 0.001))))
|
||||
}
|
||||
fn loaded_chunk(&mut self, x: i32, z: i32) -> Result<Option<()>, Error> {
|
||||
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
@@ -90,16 +135,16 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
self.item.position.y
|
||||
self.position.y
|
||||
}
|
||||
fn block_x(&self) -> i32 {
|
||||
self.item.position.x.floor() as i32
|
||||
self.position.x.floor() as i32
|
||||
}
|
||||
fn eye_y(&mut self) -> Result<f64, Error> {
|
||||
Ok(self.item.position.y + f64::from(0.2125f32))
|
||||
Ok(self.eye_y)
|
||||
}
|
||||
fn block_z(&self) -> i32 {
|
||||
self.item.position.z.floor() as i32
|
||||
self.position.z.floor() as i32
|
||||
}
|
||||
fn fluid_state(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
|
||||
@@ -114,7 +159,7 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
flow_at(self, f, p)
|
||||
}
|
||||
}
|
||||
impl FluidHeightWorld for ItemFluidWorld<'_> {
|
||||
impl FluidHeightWorld for EntityFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn fluid(&mut self, p: [i32; 3]) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2])))
|
||||
@@ -124,7 +169,7 @@ impl FluidHeightWorld for ItemFluidWorld<'_> {
|
||||
Ok(self.fluids.sturdy(state, Direction::Down))
|
||||
}
|
||||
}
|
||||
impl FluidFlowWorld for ItemFluidWorld<'_> {
|
||||
impl FluidFlowWorld for EntityFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Block = BlockStateId;
|
||||
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
@@ -214,4 +259,80 @@ mod tests {
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stands_follow_flowing_water_but_markers_and_still_water_stay_in_place() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let mut state = assets.registry.state(water).unwrap().clone();
|
||||
state.properties.insert("level".into(), "1".into());
|
||||
let flowing = assets.registry.resolve(&state).unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let stand = ArmorStand::new(1, at, 0.0);
|
||||
let mut still = stand.clone();
|
||||
let in_water = still.update_fluids(&world, &fluids);
|
||||
assert!(in_water);
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
still.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut marker = stand.clone();
|
||||
marker.marker = true;
|
||||
let in_water = marker.update_fluids(&world, &fluids);
|
||||
assert!(!in_water);
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
marker.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
assert_eq!(marker.position, at);
|
||||
assert_eq!(marker.velocity, Vec3::ZERO);
|
||||
|
||||
let mut carried = stand;
|
||||
let in_water = carried.update_fluids(&world, &fluids);
|
||||
assert!(in_water);
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
assert!((carried.position.x - (at.x + 0.014)).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - 0.014 * f64::from(0.8f32)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
let in_water = carried.update_fluids(&world, &fluids);
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,11 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
use horizon_world::{
|
||||
chunk::BlockState,
|
||||
nbt::{Compound, Tag},
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
@@ -272,7 +276,58 @@ fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile>
|
||||
slim: text("model").map(|model| model == "slim"),
|
||||
})
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
/// `BlockState.CODEC`: the block's name when the state is that block's
|
||||
/// default, `{id, properties}` for any other state.
|
||||
pub fn state_to_nbt(registry: &BlockRegistry, state: BlockStateId) -> Tag {
|
||||
match registry.state(state) {
|
||||
Some(s) if registry.default_state(&s.name) == Some(state) => string(&s.name),
|
||||
Some(s) => s.to_nbt(),
|
||||
None => string("minecraft:air"),
|
||||
}
|
||||
}
|
||||
/// `BlockState.CODEC` read back: a block name or `{id, properties}` (or the
|
||||
/// legacy `{Name, Properties}`). A name the registry knows only as a block
|
||||
/// reads as its default state, and one it does not know at all as `None`.
|
||||
pub fn state_from_nbt(registry: &BlockRegistry, tag: &Tag) -> Option<BlockStateId> {
|
||||
let state = match tag.get("Name") {
|
||||
Some(Tag::String(name)) => BlockState {
|
||||
name: name.to_string_lossy(),
|
||||
properties: match tag.get("Properties") {
|
||||
Some(Tag::Compound(p)) => p
|
||||
.iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Default::default(),
|
||||
},
|
||||
},
|
||||
_ => BlockState::from_nbt(tag).ok()?,
|
||||
};
|
||||
registry
|
||||
.id(&state)
|
||||
.or_else(|| registry.default_state(&state.name))
|
||||
}
|
||||
/// `BlockState.isAir`: air, cave air and void air.
|
||||
fn is_air(registry: &BlockRegistry, state: BlockStateId) -> bool {
|
||||
registry.state(state).is_some_and(|s| {
|
||||
matches!(
|
||||
s.name.as_str(),
|
||||
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
|
||||
)
|
||||
})
|
||||
}
|
||||
/// The mob's entity compound. `registry` is what writes a field holding a block
|
||||
/// state (an enderman's `carriedBlockState`): without one, a caller that has no
|
||||
/// resources (unit tests, structure templates) keeps whatever the loaded
|
||||
/// compound had for it.
|
||||
pub fn mob_to_nbt(
|
||||
m: &Mob,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
registry: Option<&BlockRegistry>,
|
||||
) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
uuid,
|
||||
@@ -363,11 +418,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
.iter()
|
||||
.map(|&(effect, duration, amplifier)| {
|
||||
let shown = !m.extra.hidden_effects.contains(&effect);
|
||||
let ambient = m.extra.ambient_effects.contains(&effect);
|
||||
let mut e = vec![
|
||||
("id", string(crate::brewing::namespaced(effect))),
|
||||
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
|
||||
("duration", Tag::Int(duration)),
|
||||
];
|
||||
if ambient {
|
||||
e.push(("ambient", Tag::Byte(1)));
|
||||
}
|
||||
if !shown {
|
||||
e.push(("show_particles", Tag::Byte(0)));
|
||||
}
|
||||
@@ -714,6 +773,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
|
||||
map.insert("type".into(), string(size));
|
||||
}
|
||||
// Pufferfish.addAdditionalSaveData: the puff state (0 small, 1 mid,
|
||||
// 2 full).
|
||||
"minecraft:pufferfish" => {
|
||||
map.insert("PuffState".into(), Tag::Int(m.extra.size));
|
||||
}
|
||||
// TropicalFish.addAdditionalSaveData: the packed variant.
|
||||
"minecraft:tropical_fish" => {
|
||||
let pattern = m.extra.variant.min(11);
|
||||
@@ -837,6 +901,20 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"minecraft:endermite" => {
|
||||
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
// Enderman.addAdditionalSaveData: the block it carries, saved only
|
||||
// while it carries one.
|
||||
"minecraft:enderman" => match (m.extra.carried, registry) {
|
||||
(Some(state), Some(registry)) => {
|
||||
map.insert("carriedBlockState".into(), state_to_nbt(registry, state));
|
||||
}
|
||||
// Nothing carries a block state out without the registry, and a
|
||||
// caller without one keeps what the loaded compound had, so the
|
||||
// key is only dropped when a registry could have written it.
|
||||
(Some(_), None) | (None, None) => {}
|
||||
(None, Some(_)) => {
|
||||
map.remove(&"carriedBlockState".into());
|
||||
}
|
||||
},
|
||||
"minecraft:shulker" => {
|
||||
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
@@ -1187,7 +1265,15 @@ pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
|
||||
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
|
||||
float(bonus.get("amount"))
|
||||
}
|
||||
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
|
||||
/// The mob a saved compound describes. `registry` is what reads a field holding
|
||||
/// a block state (an enderman's `carriedBlockState`); without one, such a field
|
||||
/// is left out, as it was before it was modelled.
|
||||
pub fn mob_from_nbt(
|
||||
tag: &Tag,
|
||||
types: &HashMap<String, Arc<MobType>>,
|
||||
id: u32,
|
||||
registry: Option<&BlockRegistry>,
|
||||
) -> Option<Mob> {
|
||||
let kind = types.get(&self::kind(tag)?)?.clone();
|
||||
let position = position(tag)?;
|
||||
let [yaw, pitch] = rotation(tag);
|
||||
@@ -1332,6 +1418,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
if number(e.get("show_particles")) == Some(0) {
|
||||
m.extra.hidden_effects.push(effect);
|
||||
}
|
||||
// Details.CODEC: `ambient` defaults to false.
|
||||
m.extra.ambient_effects.retain(|h| *h != effect);
|
||||
if number(e.get("ambient")).is_some_and(|a| a != 0) {
|
||||
m.extra.ambient_effects.push(effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
m.silent = flag(tag, "Silent");
|
||||
@@ -1682,6 +1773,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
if m.species() == crate::mobs::Species::IronGolem {
|
||||
m.extra.player_created = flag(tag, "PlayerCreated");
|
||||
}
|
||||
// Pufferfish.readAdditionalSaveData: `getIntOr("PuffState", 0)`, at most 2.
|
||||
if m.species() == crate::mobs::Species::Pufferfish {
|
||||
m.extra.size = number(tag.get("PuffState")).unwrap_or(0).min(2) as i32;
|
||||
}
|
||||
// Enderman.readAdditionalSaveData: the block it carried; an air state
|
||||
// reads as no block at all.
|
||||
if m.species() == crate::mobs::Species::Enderman
|
||||
&& let Some(registry) = registry
|
||||
{
|
||||
m.extra.carried = tag
|
||||
.get("carriedBlockState")
|
||||
.and_then(|t| state_from_nbt(registry, t))
|
||||
.filter(|&state| !is_air(registry, state));
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
@@ -3147,7 +3252,7 @@ mod axolotl_nbt_tests {
|
||||
Tag::compound([("test:unrelated", Tag::compound([("value", Tag::Int(9))]))]),
|
||||
)]),
|
||||
)]);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
|
||||
let memories = tag.get("Brain").unwrap().get("memories").unwrap();
|
||||
assert!(memories.get("test:unrelated").is_some());
|
||||
assert_eq!(
|
||||
@@ -3265,7 +3370,7 @@ mod axolotl_nbt_tests {
|
||||
|
||||
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 0);
|
||||
assert!(bucket_data(&m).get("HuntingCooldown").is_none());
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag));
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag), None);
|
||||
assert!(
|
||||
saved
|
||||
.get("Brain")
|
||||
@@ -3303,7 +3408,7 @@ mod sheep_nbt_tests {
|
||||
// Summoned white, then dyed red and sheared: the save has it now.
|
||||
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
|
||||
(m.extra.color, m.extra.sheared) = (14, true);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
|
||||
assert_eq!(
|
||||
(number(tag.get("Color")), number(tag.get("Sheared"))),
|
||||
(Some(14), Some(1))
|
||||
@@ -3313,7 +3418,7 @@ mod sheep_nbt_tests {
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
@@ -3339,12 +3444,12 @@ mod sheep_nbt_tests {
|
||||
)]);
|
||||
let mut m = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
m.add_effect(crate::brewing::effect("testfx:glow").unwrap().id, 200, 2);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let Some(Tag::List(saved)) = tag.get("active_effects") else {
|
||||
panic!("no active_effects");
|
||||
};
|
||||
assert_eq!(text(saved[0].get("id")).as_deref(), Some("testfx:glow"));
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.effects, [("testfx:glow", 200, 2)]);
|
||||
}
|
||||
|
||||
@@ -3368,7 +3473,7 @@ mod sheep_nbt_tests {
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(
|
||||
text(tag.get("variant")).as_deref(),
|
||||
Some("minecraft:temperate")
|
||||
@@ -3379,13 +3484,13 @@ mod sheep_nbt_tests {
|
||||
);
|
||||
// Spawned in a warm biome with the mini sounds.
|
||||
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:mini")
|
||||
);
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
|
||||
let mut hen = Mob::new(
|
||||
3,
|
||||
@@ -3394,9 +3499,9 @@ mod sheep_nbt_tests {
|
||||
0.0,
|
||||
);
|
||||
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
|
||||
let back = mob_from_nbt(&tag, &types, 4).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 4, None).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
|
||||
// The sound variants are indices into the registries finalizeSpawn draws from.
|
||||
use crate::mobs::{Species, finalize};
|
||||
@@ -3435,16 +3540,16 @@ mod sheep_nbt_tests {
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Age", Tag::Int(-21359)),
|
||||
]);
|
||||
let m = mob_from_nbt(&saved, &types, 1).unwrap();
|
||||
let m = mob_from_nbt(&saved, &types, 1, None).unwrap();
|
||||
assert_eq!(m.extra.age, -21359);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("Age")), Some(-21359));
|
||||
assert_eq!(number(tag.get("AgeLocked")), Some(0));
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
|
||||
fn mobs_keep_their_variant_puff_state_and_carried_block() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -3457,11 +3562,18 @@ mod sheep_nbt_tests {
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let registry = horizon_world::registry::BlockRegistry::from_facts_json(
|
||||
std::fs::File::open(root.join("facts.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
let round_trip = |m: &Mob| {
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
|
||||
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None, Some(®istry));
|
||||
(mob_from_nbt(&tag, &types, 2, Some(®istry)).unwrap(), tag)
|
||||
};
|
||||
// A save with nothing but where the mob stands.
|
||||
let empty =
|
||||
|kind: &str| Tag::compound([("id", string(kind)), ("Pos", doubles(&[0.5, 64.0, 0.5]))]);
|
||||
let mut fox = new("minecraft:fox");
|
||||
fox.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&fox);
|
||||
@@ -3510,13 +3622,80 @@ mod sheep_nbt_tests {
|
||||
(back.extra.screaming, back.extra.lost_horns),
|
||||
(true, [false, true])
|
||||
);
|
||||
// A goat saved without horn flags has both, as vanilla reads it.
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:goat")),
|
||||
// Pufferfish.addAdditionalSaveData: the puff state, 0 when the save
|
||||
// has none and at most 2 when it has more.
|
||||
let mut pufferfish = new("minecraft:pufferfish");
|
||||
pufferfish.extra.size = 2;
|
||||
let (back, tag) = round_trip(&pufferfish);
|
||||
assert_eq!(number(tag.get("PuffState")), Some(2));
|
||||
assert_eq!(back.extra.size, 2);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&empty("minecraft:pufferfish"), &types, 3, Some(®istry))
|
||||
.unwrap()
|
||||
.extra
|
||||
.size,
|
||||
0
|
||||
);
|
||||
let puffed = Tag::compound([
|
||||
("id", string("minecraft:pufferfish")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("PuffState", Tag::Int(5)),
|
||||
]);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
|
||||
mob_from_nbt(&puffed, &types, 3, Some(®istry))
|
||||
.unwrap()
|
||||
.extra
|
||||
.size,
|
||||
2
|
||||
);
|
||||
// Enderman.addAdditionalSaveData: the block it carries, a block name
|
||||
// for the block's default state and `{id, properties}` otherwise.
|
||||
let mut enderman = new("minecraft:enderman");
|
||||
let grass = registry.default_state("minecraft:grass_block");
|
||||
enderman.extra.carried = grass;
|
||||
let (back, tag) = round_trip(&enderman);
|
||||
assert_eq!(
|
||||
text(tag.get("carriedBlockState")).as_deref(),
|
||||
Some("minecraft:grass_block")
|
||||
);
|
||||
assert_eq!(back.extra.carried, grass);
|
||||
let log = registry
|
||||
.id(&horizon_world::chunk::BlockState {
|
||||
name: "minecraft:oak_log".into(),
|
||||
properties: [("axis".to_owned(), "x".to_owned())].into(),
|
||||
})
|
||||
.unwrap();
|
||||
enderman.extra.carried = Some(log);
|
||||
let (back, tag) = round_trip(&enderman);
|
||||
assert!(matches!(
|
||||
tag.get("carriedBlockState"),
|
||||
Some(Tag::Compound(_))
|
||||
));
|
||||
assert_eq!(back.extra.carried, Some(log));
|
||||
// Nothing carried: no key, and none read back; an air state reads as
|
||||
// no block at all.
|
||||
enderman.extra.carried = None;
|
||||
let (back, tag) = round_trip(&enderman);
|
||||
assert_eq!(tag.get("carriedBlockState"), None);
|
||||
assert_eq!(back.extra.carried, None);
|
||||
let air = Tag::compound([
|
||||
("id", string("minecraft:enderman")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("carriedBlockState", string("minecraft:air")),
|
||||
]);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&air, &types, 3, Some(®istry))
|
||||
.unwrap()
|
||||
.extra
|
||||
.carried,
|
||||
None
|
||||
);
|
||||
// A goat saved without horn flags has both, as vanilla reads it.
|
||||
assert_eq!(
|
||||
mob_from_nbt(&empty("minecraft:goat"), &types, 3, None)
|
||||
.unwrap()
|
||||
.extra
|
||||
.lost_horns,
|
||||
[false; 2]
|
||||
);
|
||||
}
|
||||
@@ -3560,6 +3739,60 @@ mod item_component_tests {
|
||||
apply_item_components(&mut sheep, &egg);
|
||||
assert_eq!(sheep.extra.color, 5, "lime, not the white it started as");
|
||||
}
|
||||
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
|
||||
/// spawned, when the type is its own — the mob's compound, the data merged
|
||||
/// over it and loaded back, as the spawn egg site does it.
|
||||
#[test]
|
||||
fn a_spawn_eggs_entity_data_reaches_the_mob_it_spawned() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let egg = |entity_data: Option<Tag>| {
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:zombie_spawn_egg", 1);
|
||||
if let Some(data) = entity_data {
|
||||
egg.data_mut()
|
||||
.set_other("minecraft:entity_data", Some(data));
|
||||
}
|
||||
egg
|
||||
};
|
||||
let spawn = |stack: &crate::items::ItemStack| {
|
||||
let mut m = Mob::new(
|
||||
2,
|
||||
types["minecraft:zombie"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
apply_item_components(&mut m, stack);
|
||||
let Some(data) =
|
||||
crate::block_entity_item::spawn_egg_entity_data(stack, &m.kind.id, false)
|
||||
else {
|
||||
return m;
|
||||
};
|
||||
let tag = crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None), data);
|
||||
mob_from_nbt(&tag, &types, 2, None).unwrap()
|
||||
};
|
||||
// `entity_data={NoAI:1b}`: no id, so the egg's own type holds the data.
|
||||
assert!(spawn(&egg(Some(Tag::compound([("NoAI", Tag::Byte(1))])))).no_ai);
|
||||
// `entity_data.id` names another type: the egg's own type, data skipped.
|
||||
let other = spawn(&egg(Some(Tag::compound([
|
||||
("id", string("minecraft:pig")),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
]))));
|
||||
assert_eq!(other.kind.id.as_str(), "minecraft:zombie");
|
||||
assert!(!other.no_ai);
|
||||
// A plain egg is unchanged.
|
||||
assert!(!spawn(&egg(None)).no_ai);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -3644,15 +3877,15 @@ mod neutral_anger_tests {
|
||||
m.extra.anger = 400;
|
||||
m.extra.anger_end = 3000;
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
|
||||
assert!(tag.get("angry_at").is_some());
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.anger_end, 3000);
|
||||
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
|
||||
// A mob that is no NeutralMob writes no anger.
|
||||
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None);
|
||||
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None);
|
||||
assert!(tag.get("anger_end_time").is_none());
|
||||
// The anger that is left when the mob comes back.
|
||||
assert_eq!(crate::mobs::anger_left(3000, 1000), 2000);
|
||||
@@ -3682,8 +3915,8 @@ mod held_item_name_tests {
|
||||
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
|
||||
sword.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Old Sword".into()));
|
||||
mob.extra.set_main_hand_stack(Some(sword));
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None);
|
||||
let mut loaded = mob_from_nbt(&raw, &types, 2).unwrap();
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
|
||||
let mut loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
loaded
|
||||
.extra
|
||||
@@ -3696,8 +3929,8 @@ mod held_item_name_tests {
|
||||
loaded
|
||||
.extra
|
||||
.set_main_hand(Some("minecraft:diamond_sword".into()));
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw));
|
||||
let restored = mob_from_nbt(&saved, &types, 3).unwrap();
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw), None);
|
||||
let restored = mob_from_nbt(&saved, &types, 3, None).unwrap();
|
||||
assert_eq!(
|
||||
restored.extra.main_hand.as_deref(),
|
||||
Some("minecraft:diamond_sword")
|
||||
|
||||
@@ -547,6 +547,17 @@ impl HandSway {
|
||||
let y_bob = crate::camera::lerp(partial, self.y_bob_o, self.y_bob);
|
||||
[(view_x_rot - x_bob) * 0.1, (view_y_rot - y_bob) * 0.1]
|
||||
}
|
||||
/// The hands' view space to camera-relative world space, turned by the
|
||||
/// sway. The hand pass draws with the camera's view, whose rotation a
|
||||
/// ridden boat's turn spreads over the frames of a tick
|
||||
/// (`LocalPlayer.getViewYRot`): anything else swings the hands back and
|
||||
/// forth by that turn, up to 10 degrees, 20 times a second (#4583).
|
||||
pub fn to_world(&self, partial: f32, camera: &crate::camera::Camera) -> glam::Mat4 {
|
||||
let [x, y] = self.rotation(partial, camera.x_rot, camera.y_rot);
|
||||
crate::camera::view_matrix(camera.y_rot, camera.x_rot).inverse()
|
||||
* glam::Mat4::from_rotation_x(x.to_radians())
|
||||
* glam::Mat4::from_rotation_y(y.to_radians())
|
||||
}
|
||||
}
|
||||
|
||||
/// `HitResult` at the crosshair (`Minecraft.hitResult`).
|
||||
@@ -966,6 +977,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #4583: while a boat turns the player steadily, the hands keep their
|
||||
/// place on screen through every frame of a tick.
|
||||
#[test]
|
||||
fn the_hands_hold_still_on_screen_while_a_boat_turns() {
|
||||
const TURN: f32 = 10.0;
|
||||
let mut sway = HandSway::default();
|
||||
let mut yaw = 0.0;
|
||||
for _ in 0..30 {
|
||||
sway.tick(0.0, yaw);
|
||||
yaw += TURN;
|
||||
}
|
||||
let on_screen = |partial: f32| {
|
||||
let camera = crate::camera::Camera {
|
||||
y_rot: yaw - TURN * (1.0 - partial),
|
||||
..Default::default()
|
||||
};
|
||||
crate::camera::view_matrix(camera.y_rot, 0.0) * sway.to_world(partial, &camera)
|
||||
};
|
||||
let first = on_screen(0.0);
|
||||
for partial in [0.25, 0.5, 0.75, 0.99] {
|
||||
let frame = on_screen(partial);
|
||||
assert!(frame.abs_diff_eq(first, 1.0e-3), "{partial}: {frame:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() {
|
||||
let (bow, apple) = (
|
||||
|
||||
@@ -161,6 +161,7 @@ mod maps;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mob_looting;
|
||||
mod mob_sounds;
|
||||
mod mods;
|
||||
mod mount_menu;
|
||||
@@ -3724,35 +3725,46 @@ impl Game<'_> {
|
||||
self.tick_chunks.recycle_added(added);
|
||||
}
|
||||
/// `DistanceManager`'s tickets of the level in place, from every player in
|
||||
/// it (the local player, LAN guests, a server's players), its forced
|
||||
/// chunks and its portal tickets. A game with neither a server nor a
|
||||
/// player (the headless fixtures) ticks everywhere.
|
||||
/// it (the local player, LAN guests, a server's players; a spectator the
|
||||
/// rule skips holds none), its forced chunks and its portal tickets. A game
|
||||
/// with neither a server nor a player (the headless fixtures) ticks
|
||||
/// everywhere.
|
||||
fn update_chunk_tickets(&mut self) {
|
||||
let mut players: Vec<(i32, i32)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
let rule = self.ui.rules.bool("spectators_generate_chunks");
|
||||
let mut players: Vec<((i32, i32), bool)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
.map(|g| (g.chunk(), g.spectator()))
|
||||
.collect()
|
||||
});
|
||||
if let Some(p) = &self.player {
|
||||
players.push((
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
(
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
),
|
||||
self.spectator(),
|
||||
));
|
||||
}
|
||||
let sources =
|
||||
(!self.options.execution_role.is_client() || !players.is_empty()).then(|| {
|
||||
horizon_client::ticket_levels::Sources {
|
||||
players,
|
||||
forced: self.streaming.forced.iter().copied().collect(),
|
||||
// Filled from the `PORTAL` tickets by `update`.
|
||||
portal: Vec::new(),
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.simulation_distance.max(2),
|
||||
}
|
||||
});
|
||||
// A spectator the rule skips is still a player: a game that has one
|
||||
// must not take the headless fixtures' tick-everywhere case below.
|
||||
let any_player = !players.is_empty();
|
||||
let sources = (!self.options.execution_role.is_client() || any_player).then(|| {
|
||||
horizon_client::ticket_levels::Sources {
|
||||
players: players
|
||||
.into_iter()
|
||||
.filter(|&(_, spectator)| game_mode::loads_chunks(rule, spectator))
|
||||
.map(|(chunk, _)| chunk)
|
||||
.collect(),
|
||||
forced: self.streaming.forced.iter().copied().collect(),
|
||||
// Filled from the `PORTAL` tickets by `update`.
|
||||
portal: Vec::new(),
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.simulation_distance.max(2),
|
||||
}
|
||||
});
|
||||
self.tickets.update(sources);
|
||||
}
|
||||
/// `ServerLevel.startTickingChunk`: a loaded chunk's saved ticks are
|
||||
@@ -9464,23 +9476,23 @@ impl Game<'_> {
|
||||
p.z + (q.z - p.z) * a,
|
||||
)
|
||||
};
|
||||
let mut casters: Vec<(glam::DVec3, &str, f32)> =
|
||||
let mut casters: Vec<(glam::DVec3, f32, f32)> =
|
||||
horizon_client::shadows::mob_casters(&self.mobs, a).collect();
|
||||
let fixed = |id: &'static str| (id, horizon_client::shadows::shadow_params(id).0);
|
||||
let (item, item_radius) = fixed("minecraft:item");
|
||||
let item = horizon_client::shadows::shadow_params("minecraft:item");
|
||||
casters.extend(
|
||||
self.dropped
|
||||
.iter()
|
||||
.map(|i| (lerp(i.previous, i.position), item, item_radius)),
|
||||
.map(|i| (lerp(i.previous, i.position), item.0, item.1)),
|
||||
);
|
||||
let (orb, orb_radius) = fixed("minecraft:experience_orb");
|
||||
let orb = horizon_client::shadows::shadow_params("minecraft:experience_orb");
|
||||
casters.extend(
|
||||
self.orbs
|
||||
.iter()
|
||||
.map(|o| (lerp(o.previous, o.position), orb, orb_radius)),
|
||||
.map(|o| (lerp(o.previous, o.position), orb.0, orb.1)),
|
||||
);
|
||||
for (pos, id, radius) in casters {
|
||||
let strength = horizon_client::shadows::shadow_params(id).1;
|
||||
// Text, block and item displays (DisplayRenderer's shadow radius).
|
||||
casters.extend(self.text_displays.values().filter_map(|d| d.shadow(a)));
|
||||
for (pos, radius, strength) in casters {
|
||||
horizon_client::shadows::shadow(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -9874,14 +9886,10 @@ impl Game<'_> {
|
||||
target_kind: &str,
|
||||
living: bool,
|
||||
) -> (f32, f32, bool, bool) {
|
||||
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
let hand_damage = self.attack_damage();
|
||||
// Riptide's spin replaces the attack damage attribute.
|
||||
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
|
||||
let damage = if spin > 0.0 {
|
||||
spin
|
||||
} else {
|
||||
hand_damage + self.effect_attack_bonus()
|
||||
};
|
||||
let damage = if spin > 0.0 { spin } else { hand_damage };
|
||||
let scale = self.attack_strength_scale();
|
||||
// getEnchantedDamage(target, damage, source) - damage, taken before the
|
||||
// attack strength scales the base damage.
|
||||
@@ -10402,8 +10410,8 @@ impl Game<'_> {
|
||||
creative,
|
||||
options,
|
||||
} => self.open_world(&folder, Some(&name), seed, creative, Some(&options))?,
|
||||
Action::Connect { address } => {
|
||||
self.connect_server(&address, &self.options.player_name())?
|
||||
Action::Connect { address, lan } => {
|
||||
self.connect_server_with_options(&address, &self.options.player_name(), None, lan)?
|
||||
}
|
||||
Action::DataPacks { dir, selected } => {
|
||||
self.keys.clear();
|
||||
@@ -12171,12 +12179,8 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
let heights = self.heights.interpolated(a);
|
||||
let view = horizon_client::camera::view_matrix(self.yaw, self.pitch);
|
||||
// The hand sway: both hands turn by a tenth of the bobs' lag.
|
||||
let [sway_x, sway_y] = self.sway.rotation(a, self.pitch, self.yaw);
|
||||
let to_world = view.inverse()
|
||||
* glam::Mat4::from_rotation_x(sway_x.to_radians())
|
||||
* glam::Mat4::from_rotation_y(sway_y.to_radians());
|
||||
let to_world = self.sway.to_world(a, &self.view);
|
||||
let eye = self.eye_position().unwrap_or_default();
|
||||
let light = self.world.light(
|
||||
eye[0].floor() as i32,
|
||||
@@ -13352,10 +13356,16 @@ impl Game<'_> {
|
||||
horizon_client::level_loading::draw_progress(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
if loader.cancelled {
|
||||
"Cancelling world"
|
||||
&if loader.cancelled {
|
||||
horizon_client::lang::get_or(
|
||||
"horizon.loading.cancelling_world",
|
||||
"Cancelling world",
|
||||
)
|
||||
} else {
|
||||
"Opening world"
|
||||
horizon_client::lang::get_or(
|
||||
"horizon.loading.opening_world",
|
||||
"Opening world",
|
||||
)
|
||||
},
|
||||
0.0,
|
||||
);
|
||||
@@ -15356,7 +15366,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
// Screen.keyPressed sees the physical key, repeats included.
|
||||
if event.state == ElementState::Pressed
|
||||
&& self.screen == Screen::Inventory
|
||||
&& self.merchant_key(key)
|
||||
&& (self.merchant_key(key) || self.recipe_book_key(key))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -16257,6 +16267,7 @@ enum PendingWorldEntry {
|
||||
Server {
|
||||
address: String,
|
||||
name: String,
|
||||
lan: bool,
|
||||
/// A transfer's carried-over server cookies (`TransferState`).
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
},
|
||||
@@ -16354,8 +16365,9 @@ impl Game<'_> {
|
||||
address,
|
||||
name,
|
||||
cookies,
|
||||
lan,
|
||||
}) => {
|
||||
self.connect_server_with_cookies(&address, &name, cookies)?;
|
||||
self.connect_server_with_options(&address, &name, cookies, lan)?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,17 @@ impl Game<'_> {
|
||||
.map(|(k, _)| *k)
|
||||
.collect()
|
||||
});
|
||||
let dancing = jukeboxes.iter().any(|k| {
|
||||
(f64::from(k.0) + 0.5 - pos.x).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
|
||||
<= 100.0
|
||||
});
|
||||
self.mobs[i].extra.flag =
|
||||
dancing && !horizon_client::mobs::goals::should_panic(&self.mobs[i]);
|
||||
let nearest = jukeboxes
|
||||
.iter()
|
||||
.map(|k| {
|
||||
(f64::from(k.0) + 0.5 - pos.x).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
// `Allay.setDancing` wants effective AI: a `NoAI` allay never dances.
|
||||
let panicking = horizon_client::mobs::goals::should_panic(&self.mobs[i]);
|
||||
self.mobs[i].extra.flag = allay::dances(self.mobs[i].no_ai, panicking, nearest);
|
||||
let Some(held) = self.mobs[i].extra.main_hand_stack().cloned() else {
|
||||
self.mobs[i].extra.point = None;
|
||||
continue;
|
||||
@@ -47,9 +50,23 @@ impl Game<'_> {
|
||||
.items()
|
||||
.get(&held.item)
|
||||
.map_or(64, |i| i.max_stack as i32);
|
||||
// GoToWantedItem / wantsToPickUp: a stack it considers equal within 32
|
||||
// that the allay sees (NearestItemSensor's hasLineOfSight, eye to eye).
|
||||
let wanted = if griefing && carried < max && self.mobs[i].extra.ints[3] <= 0 {
|
||||
// `Mob.aiStep`'s looting loop asks for no AI: `canPickUpLoot`, the
|
||||
// inventory room in `wantsToPickUp` and mob_griefing are its gates.
|
||||
let wants = self.mobs[i].can_pick_up_loot() && griefing && carried < max;
|
||||
let reach = allay::pickup_reach(self.mobs[i].bounds());
|
||||
// GoToWantedItem: a stack it considers equal within 32 that the
|
||||
// allay sees (NearestItemSensor's hasLineOfSight, eye to eye). The
|
||||
// sensor is the brain's, so without AI only `Mob.aiStep`'s looting
|
||||
// loop runs: it takes an equal drop already in `getPickupReach`.
|
||||
let wanted = if !wants {
|
||||
None
|
||||
} else if self.mobs[i].no_ai {
|
||||
self.dropped.iter().position(|e| {
|
||||
e.pickup_delay == 0
|
||||
&& allay::considers_item_equal(&held, &e.stack)
|
||||
&& reach.contains(e.position)
|
||||
})
|
||||
} else {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let eye = horizon_core::geometry::Vec3::new(pos.x, self.mobs[i].eye_y(), pos.z);
|
||||
// The sensor looks in the allay's box grown by (32, 16, 32).
|
||||
@@ -78,20 +95,18 @@ impl Game<'_> {
|
||||
w.line_of_sight(eye, at)
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
} else {
|
||||
None
|
||||
.map(|(j, _)| j)
|
||||
};
|
||||
let Some((j, _)) = wanted else {
|
||||
let Some(j) = wanted else {
|
||||
self.mobs[i].extra.point = None;
|
||||
self.allay_deliver(i);
|
||||
// `GiveInventoryToLookTargetPlayer` is the brain's too.
|
||||
if !self.mobs[i].no_ai {
|
||||
self.allay_deliver(i);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let e = &self.dropped[j];
|
||||
// getPickupReach (1, 1, 1) around the box.
|
||||
let b = self.mobs[i]
|
||||
.bounds()
|
||||
.inflate(horizon_core::geometry::Vec3::new(1.0, 1.0, 1.0));
|
||||
if b.contains(e.position) {
|
||||
if reach.contains(e.position) {
|
||||
let take = (max - carried).min(e.stack.count as i32);
|
||||
let (item, id) = (e.clone(), self.mobs[i].id);
|
||||
// It carries the fetched stack, potion and all, not just its id.
|
||||
|
||||
@@ -707,14 +707,14 @@ impl Game<'_> {
|
||||
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
let in_water = a.in_water(&self.world, &self.res.motion().fluids);
|
||||
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
|
||||
let (world, registry, facts, collision) = (
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
a.tick(&mut scene, in_water, &mut |bounds, velocity| {
|
||||
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
|
||||
horizon_client::motion::bubble_column_push(
|
||||
world, registry, facts, collision, bounds, velocity,
|
||||
);
|
||||
|
||||
@@ -175,7 +175,7 @@ impl Game<'_> {
|
||||
.effects
|
||||
.iter()
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.filter_map(|e| effect_particle(e.0, false))
|
||||
.filter_map(|e| effect_particle(e.0, m.extra.ambient_effects.contains(&e.0)))
|
||||
.collect();
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
|
||||
@@ -91,6 +91,9 @@ pub(super) struct CombatTracker {
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
pub(super) refuse_player_hit: bool,
|
||||
/// A firework rocket's burst is hurting the player now: the rocket is
|
||||
/// the direct entity (`rocket_hurt_player`).
|
||||
pub(super) rocket_hit: bool,
|
||||
/// The player whose melee hit is landing on the player now: where they
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
@@ -112,6 +115,10 @@ pub(super) struct CombatTracker {
|
||||
/// skull, wither or TNT minecart, the sources that empty a hive they
|
||||
/// destroy (`BeehiveBlock.getDrops`).
|
||||
pub(super) hive_emptying_blast: bool,
|
||||
/// The blast now going off comes from a primed TNT minecart, which
|
||||
/// neither resists on nor breaks a rail or the block under one
|
||||
/// (`MinecartTNT.getBlockExplosionResistance`, `shouldBlockExplode`).
|
||||
pub(super) rail_proof_blast: bool,
|
||||
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
|
||||
/// the mobs the blast hurts record it as their attacker.
|
||||
pub(super) explosion_owner: Option<u32>,
|
||||
@@ -329,31 +336,32 @@ impl Game<'_> {
|
||||
.find(|m| m.id == *id)
|
||||
.map(|m| self.mob_entity(m))
|
||||
});
|
||||
let projectile = matches!(
|
||||
e.damage_type.as_str(),
|
||||
"minecraft:arrow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:mob_projectile"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:unattributed_fireball"
|
||||
| "minecraft:wither_skull"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:thrown"
|
||||
| "minecraft:spit"
|
||||
);
|
||||
let direct = if projectile {
|
||||
Some(EntityView {
|
||||
kind: e.damage_type.clone(),
|
||||
let projectile = match e.damage_type.as_str() {
|
||||
// The rocket is the direct entity of its `fireworks` damage.
|
||||
"minecraft:fireworks" => Some("minecraft:firework_rocket"),
|
||||
t @ ("minecraft:arrow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:mob_projectile"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:unattributed_fireball"
|
||||
| "minecraft:wither_skull"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:thrown"
|
||||
| "minecraft:spit") => Some(t),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
Some(kind) => Some(EntityView {
|
||||
kind: kind.to_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
source.clone()
|
||||
}),
|
||||
None => source.clone(),
|
||||
};
|
||||
Some(DamageView {
|
||||
kind: e.damage_type.clone(),
|
||||
direct,
|
||||
source,
|
||||
is_direct: !projectile,
|
||||
is_direct: projectile.is_none(),
|
||||
original: e.damage,
|
||||
actual: e.damage,
|
||||
blocked: false,
|
||||
@@ -561,9 +569,15 @@ impl Game<'_> {
|
||||
.map(|e| {
|
||||
let attacker = e.source.attacker.and_then(|w| name(self, w));
|
||||
// AbstractArrow.onHitEntity: an arrow nobody shot is its own
|
||||
// cause.
|
||||
let direct = (attacker.is_none() && e.source.kind == DamageKind::Arrow)
|
||||
.then(|| Text::translate("entity.minecraft.arrow", &[]));
|
||||
// cause; a rocket nobody fired still names itself.
|
||||
let direct = match e.source.kind {
|
||||
_ if attacker.is_some() => None,
|
||||
DamageKind::Arrow => Some(Text::translate("entity.minecraft.arrow", &[])),
|
||||
DamageKind::Fireworks => {
|
||||
Some(Text::translate("entity.minecraft.firework_rocket", &[]))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Entry {
|
||||
damage_type: super::creatures::source_damage_type(e.source).to_owned(),
|
||||
damage: e.damage,
|
||||
|
||||
@@ -149,8 +149,9 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
|
||||
/// rebuilt from its compound, keeping its id and UUID.
|
||||
fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
|
||||
/// rebuilt from its compound, keeping its id and UUID. Also what
|
||||
/// `TypedEntityData.loadInto` is: a spawn egg's data on the mob it spawned.
|
||||
pub(in crate::game) fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
|
||||
let stale = || err("commands.data.merge.failed", &[]);
|
||||
match t {
|
||||
Target::Player => return Err(err("commands.data.entity.invalid", &[])),
|
||||
@@ -165,8 +166,13 @@ impl Game<'_> {
|
||||
if let Some(k) = kind {
|
||||
set_key(&mut tag, "id", Tag::String(k.as_str().into()));
|
||||
}
|
||||
let new =
|
||||
entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), id).ok_or_else(stale)?;
|
||||
let new = entity_nbt::mob_from_nbt(
|
||||
&tag,
|
||||
self.res.mob_types(),
|
||||
id,
|
||||
Some(self.pack.assets.registry),
|
||||
)
|
||||
.ok_or_else(stale)?;
|
||||
if let Some(slot) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
// Runtime-only state (AI, animation) carries over.
|
||||
let old = std::mem::replace(slot, new);
|
||||
|
||||
@@ -73,11 +73,8 @@ impl Game<'_> {
|
||||
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
Some(m) => {
|
||||
let name = namespaced(effect.id);
|
||||
m.add_effect(name, duration, amplifier);
|
||||
m.extra.hidden_effects.retain(|h| *h != name);
|
||||
if !particles {
|
||||
m.extra.hidden_effects.push(name);
|
||||
}
|
||||
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
|
||||
m.add_effect_with(name, duration, amplifier, false, particles);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -144,6 +141,9 @@ impl Game<'_> {
|
||||
m.extra
|
||||
.hidden_effects
|
||||
.retain(|h| left.iter().any(|x| x.0 == *h));
|
||||
m.extra
|
||||
.ambient_effects
|
||||
.retain(|h| left.iter().any(|x| x.0 == *h));
|
||||
m.extra.effects.len() != before
|
||||
}
|
||||
None => false,
|
||||
|
||||
@@ -345,8 +345,11 @@ impl Game<'_> {
|
||||
if action == "tap" {
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// The merchant's focused offer takes Enter and Space, as the window's.
|
||||
if action != "up" && self.screen == Screen::Inventory && self.merchant_key(physical)
|
||||
// The merchant's focused offer and the recipe book's last
|
||||
// recipe take Enter and Space, as the window's.
|
||||
if action != "up"
|
||||
&& self.screen == Screen::Inventory
|
||||
&& (self.merchant_key(physical) || self.recipe_book_key(physical))
|
||||
{
|
||||
return ok();
|
||||
}
|
||||
|
||||
@@ -391,6 +391,7 @@ impl Game<'_> {
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
self.enchant_spawn_equipment(id);
|
||||
self.halloween_spawn_head(id);
|
||||
if !persistent && !scripted {
|
||||
self.spawn_jockeys(id, true);
|
||||
}
|
||||
@@ -572,6 +573,7 @@ impl Game<'_> {
|
||||
block_tags: &self.items().block_tags,
|
||||
biomes: self.res.biomes(),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
universal_anger: self.ui.rules.bool("universal_anger"),
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
|
||||
end: self.dim == Dim::End,
|
||||
@@ -724,6 +726,7 @@ impl Game<'_> {
|
||||
.map(super::boat_leads::boat_state)
|
||||
.collect();
|
||||
let base = self.mob_world_base(&[]);
|
||||
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
|
||||
// Each mob keeps its snapshot slot all tick; the first slot of an
|
||||
// id answers, as a scan would.
|
||||
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
|
||||
@@ -784,7 +787,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &w, &path_writes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
@@ -889,6 +892,7 @@ impl Game<'_> {
|
||||
self.tick_fangs();
|
||||
self.tick_allays();
|
||||
self.tick_pandas();
|
||||
self.tick_mob_looting();
|
||||
self.tick_breeze_effects();
|
||||
self.tick_sniffer_effects();
|
||||
self.piglin_pickups();
|
||||
@@ -1054,6 +1058,7 @@ impl Game<'_> {
|
||||
let projectile = match d.kind {
|
||||
DamageKind::Arrow => Some("minecraft:arrow"),
|
||||
DamageKind::Fireball => Some("minecraft:fireball"),
|
||||
DamageKind::Fireworks => Some("minecraft:firework_rocket"),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
@@ -1155,6 +1160,10 @@ impl Game<'_> {
|
||||
kind: "minecraft:fireball".into(),
|
||||
..LootEntity::default()
|
||||
}),
|
||||
DamageKind::Fireworks => Some(LootEntity {
|
||||
kind: "minecraft:firework_rocket".into(),
|
||||
..LootEntity::default()
|
||||
}),
|
||||
_ => source.clone(),
|
||||
};
|
||||
LootDamage {
|
||||
@@ -1539,12 +1548,20 @@ impl Game<'_> {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
// The creeper causes the blast's damage, but it is out of
|
||||
// the mob list already: its name comes with the event.
|
||||
self.combat.exploded.push((mob, name.clone()));
|
||||
self.note_named_attacker("minecraft:explosion", mob, name);
|
||||
self.combat.exploded.push((mob, name));
|
||||
if charged {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::LingeringCloud {
|
||||
at,
|
||||
effects,
|
||||
hidden,
|
||||
ambient,
|
||||
color,
|
||||
} => {
|
||||
self.spawn_creeper_cloud(at, effects, hidden, ambient, color);
|
||||
}
|
||||
MobEvent::Explode(at, radius, species) => {
|
||||
// The exploding mob causes the damage (Creeper.explodeCreeper).
|
||||
let noted = self.combat.explosion_source.is_some();
|
||||
@@ -1558,9 +1575,6 @@ impl Game<'_> {
|
||||
.map(|m| m.id)
|
||||
.next()
|
||||
});
|
||||
if !noted && let Some(id) = source_mob {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
self.combat.explosion_source_named = noted;
|
||||
self.combat.explosion_cause = match species {
|
||||
@@ -2676,17 +2690,21 @@ impl Game<'_> {
|
||||
damage_type: &str,
|
||||
known: Option<Option<u32>>,
|
||||
) -> bool {
|
||||
let rocket = std::mem::take(&mut self.combat.rocket_hit);
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
return false;
|
||||
}
|
||||
let projectile = self
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
// A projectile entity dealt it: `#is_projectile`, or a firework
|
||||
// rocket (its `fireworks` type is not in the tag).
|
||||
let projectile = rocket
|
||||
|| self
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
let blocking = self.player_blocking(from, damage_type, false);
|
||||
let raised = blocking.is_some();
|
||||
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
|
||||
@@ -2767,7 +2785,17 @@ impl Game<'_> {
|
||||
.map(|m| self.mob_entity(m));
|
||||
self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView {
|
||||
kind: damage_type.to_owned(),
|
||||
direct: source.clone().filter(|_| !projectile),
|
||||
// Arrows and the like name no direct entity here; a
|
||||
// rocket's burst names the rocket.
|
||||
direct: if rocket {
|
||||
Some(horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:firework_rocket".to_owned(),
|
||||
position: from.unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
source.clone().filter(|_| !projectile)
|
||||
},
|
||||
is_direct: !projectile,
|
||||
source,
|
||||
original: amount,
|
||||
@@ -3935,6 +3963,13 @@ impl Game<'_> {
|
||||
Hit::None => {}
|
||||
},
|
||||
(ProjectileKind::LargeFireball { power }, hit) => {
|
||||
// Fireball explosions are caused by the shooter. A direct hit
|
||||
// on the player takes `projectile_owner`, so read the owner
|
||||
// from the fireball.
|
||||
let owner = match p.owner {
|
||||
Some(Who::Mob(id)) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
if let Hit::Entity { who, .. } = hit {
|
||||
match who {
|
||||
Who::Player => {
|
||||
@@ -3956,11 +3991,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fireball explosions are caused by the shooter.
|
||||
if let Some(owner) = self.combat.projectile_owner {
|
||||
self.note_attacker("minecraft:explosion", owner);
|
||||
}
|
||||
self.combat.explosion_owner = self.combat.projectile_owner;
|
||||
self.combat.explosion_owner = owner;
|
||||
// The blast's source names the owner, so a dragon it kills
|
||||
// remembers the player who shot it.
|
||||
self.set_blast_by_player(p.owner == Some(Who::Player));
|
||||
@@ -4881,7 +4912,15 @@ impl Game<'_> {
|
||||
// ALTERNATIVE_WITH_SPEED with a per-axis maximum speed.
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, 0.2, count);
|
||||
self.lan_send_particles(&options, at, spread, 0.2, count);
|
||||
self.lan_send_particles(super::lan_particles::QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options,
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed: 0.2,
|
||||
count: count as i32,
|
||||
});
|
||||
}
|
||||
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
|
||||
if let Some(stack) = &held {
|
||||
@@ -5179,6 +5218,7 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.apply_spawn_egg_entity_data(id, stack);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
@@ -5190,6 +5230,25 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
|
||||
/// the mob it spawned, once the guards let it through, as
|
||||
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
|
||||
/// it, loaded back into the same mob (`/data merge entity`'s loader).
|
||||
fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let op = self.acting_player_is_op();
|
||||
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let own = self.mob_tree_nbt(i);
|
||||
let tag = horizon_client::data_cmd::merged(&own, data);
|
||||
// Nothing to report: the mob's own compound always loads back.
|
||||
let _ = self.apply_entity_nbt(super::commands::Target::Mob(id), tag);
|
||||
}
|
||||
/// A beam target's centre (half its height), interpolated.
|
||||
fn beam_target(&self, who: Option<Who>, alpha: f64) -> Option<glam::DVec3> {
|
||||
match who? {
|
||||
@@ -6094,6 +6153,7 @@ mod pearl_tests {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0),
|
||||
|
||||
@@ -704,6 +704,8 @@ impl Game<'_> {
|
||||
self.server_lists = Some(lists.clone());
|
||||
log("INFO", &format!("Preparing level \"{level_name}\""));
|
||||
let folder = world_dir.to_string_lossy().into_owned();
|
||||
// MinecraftServer.loadLevel's start: nothing is loaded yet.
|
||||
self.native_server_about_to_start(true);
|
||||
self.native_lifecycle_open_started();
|
||||
// The registries the server sends are read while the world prepares.
|
||||
let sync = super::lan::preload_sync_data(&self.options.resources);
|
||||
@@ -1039,11 +1041,12 @@ impl Game<'_> {
|
||||
/// nobody is online a dedicated server keeps none (vanilla has no spawn
|
||||
/// ticket), an integrated one the spawn chunk's.
|
||||
pub(super) fn update_dedicated_streaming(&mut self) {
|
||||
let rule = self.ui.rules.bool("spectators_generate_chunks");
|
||||
let guest = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.find(|g| h.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator()))
|
||||
.map(|g| g.chunk())
|
||||
});
|
||||
if self.standalone_server() {
|
||||
@@ -1677,6 +1680,8 @@ impl Game<'_> {
|
||||
fn stop_dedicated(&mut self) -> Result<()> {
|
||||
// The close request starts before player and world saves.
|
||||
self.native_lifecycle.begin_close();
|
||||
// MinecraftServer.stopServer's start: everyone is still here.
|
||||
self.native_server_stopping(true);
|
||||
log("INFO", "Stopping server");
|
||||
log("INFO", "Saving players");
|
||||
if let Some(host) = self.lan.take() {
|
||||
|
||||
@@ -122,14 +122,6 @@ impl Game<'_> {
|
||||
base,
|
||||
)
|
||||
}
|
||||
/// Strength / weakness on the attack damage attribute.
|
||||
pub(super) fn effect_attack_bonus(&self) -> f32 {
|
||||
let fx = &self.potions.effects;
|
||||
fx.amplifier("strength")
|
||||
.map_or(0.0, |a| 3.0 * (a + 1) as f32)
|
||||
- fx.amplifier("weakness")
|
||||
.map_or(0.0, |a| 4.0 * (a + 1) as f32)
|
||||
}
|
||||
/// `Player.attack`'s extra knockback: `getKnockback` (the weapon's knockback
|
||||
/// enchantment, halved) plus 0.5 for a sprinting full-strength hit, and
|
||||
/// whether the hit is that knockback attack.
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! draws), entity damage with exposure and knockback, block interaction with
|
||||
//! merged drops and TNT chain priming, and optional fire.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack};
|
||||
use horizon_client::{
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
@@ -136,6 +138,30 @@ fn hurt_and_push_mob(
|
||||
m.needs_sync = true;
|
||||
}
|
||||
|
||||
/// A blast's causing entity and its name (`Explosion.getIndirectSourceEntity`):
|
||||
/// the mob that owns the exploding projectile while it is in the level
|
||||
/// (`Projectile.getOwner` is null once it is removed), else the exploding
|
||||
/// mob. `live` names a mob in the level, `exploded` the mobs whose blast
|
||||
/// went off this tick and that already left it.
|
||||
fn blast_cause(
|
||||
owner: Option<u32>,
|
||||
exploding: Option<u32>,
|
||||
live: impl Fn(u32) -> Option<Text>,
|
||||
exploded: &[(u32, Text)],
|
||||
) -> Option<(u32, Text)> {
|
||||
if let Some(owner) = owner {
|
||||
return live(owner).map(|name| (owner, name));
|
||||
}
|
||||
let id = exploding?;
|
||||
let name = live(id).or_else(|| {
|
||||
exploded
|
||||
.iter()
|
||||
.rfind(|(m, _)| *m == id)
|
||||
.map(|(_, n)| n.clone())
|
||||
})?;
|
||||
Some((id, name))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Client-side randomness (explosion sound pitch), separate from the level random.
|
||||
static CLIENT_RANDOM: std::cell::RefCell<LegacyRandom> = std::cell::RefCell::new(LegacyRandom::new(0x5eed));
|
||||
@@ -145,6 +171,15 @@ pub(super) fn client_float() -> f32 {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `blast_cause` for the blast going off now.
|
||||
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
|
||||
blast_cause(
|
||||
self.combat.explosion_owner,
|
||||
self.combat.explosion_source,
|
||||
|id| self.mob_display_name(id),
|
||||
&self.combat.exploded,
|
||||
)
|
||||
}
|
||||
/// `ServerExplosion.getSeenPercent` for an entity box.
|
||||
pub(super) fn seen_percent(&self, center: [f64; 3], min: [f64; 3], max: [f64; 3]) -> f32 {
|
||||
let xs = 1.0 / ((max[0] - min[0]) * 2.0 + 1.0);
|
||||
@@ -249,16 +284,26 @@ impl Game<'_> {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// A primed TNT minecart's rails and the blocks they
|
||||
// stand on: no resistance, and never blown up.
|
||||
let spared = self.combat.rail_proof_blast
|
||||
&& (self.rail(pos[0], pos[1], pos[2]).is_some()
|
||||
|| self.rail(pos[0], pos[1] + 1, pos[2]).is_some());
|
||||
match resists {
|
||||
Some((at, resistance)) if at == pos => {
|
||||
power -= (resistance + 0.3) * 0.3;
|
||||
}
|
||||
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
|
||||
power -= (f.explosion_resistance.max(fluid) + 0.3) * 0.3;
|
||||
let resistance = if spared {
|
||||
0.0
|
||||
} else {
|
||||
f.explosion_resistance.max(fluid)
|
||||
};
|
||||
power -= (resistance + 0.3) * 0.3;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if power > 0.0 {
|
||||
if power > 0.0 && !spared {
|
||||
set.put(Key(pos), ());
|
||||
}
|
||||
xp += xd * f64::from(0.3f32);
|
||||
@@ -288,6 +333,7 @@ impl Game<'_> {
|
||||
radius: f32,
|
||||
damage_type: &str,
|
||||
exploding: Option<u32>,
|
||||
attacker: Option<&(u32, Text)>,
|
||||
) -> Option<[f64; 3]> {
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
|
||||
@@ -341,6 +387,11 @@ impl Game<'_> {
|
||||
let flying = self.player.as_ref().is_some_and(|p| p.flying);
|
||||
let mut outcome = horizon_client::health::BlockOutcome::default();
|
||||
let mut pushed = None;
|
||||
// The death message names the blast's causing entity. It goes to this
|
||||
// player's tracker, the host's or a guest's.
|
||||
if let Some((id, name)) = attacker.filter(|_| !creative && !spectator && hurtable) {
|
||||
self.note_named_attacker(damage_type, *id, name.clone());
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
{
|
||||
@@ -354,8 +405,7 @@ impl Game<'_> {
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
// The exploding mob is the causing entity.
|
||||
exploding.is_some(),
|
||||
attacker.is_some(),
|
||||
)
|
||||
{
|
||||
outcome = o;
|
||||
@@ -429,7 +479,9 @@ impl Game<'_> {
|
||||
// The player, then every other player in the level (LAN guests),
|
||||
// each swapped in as the player. In a guest's turn (their hit on an
|
||||
// end crystal) the player is that guest.
|
||||
let own = self.explosion_hurt_player(center, radius, damage_type, exploding);
|
||||
let attacker = self.blast_attacker();
|
||||
let own =
|
||||
self.explosion_hurt_player(center, radius, damage_type, exploding, attacker.as_ref());
|
||||
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
let guests: Vec<u32> = host
|
||||
@@ -441,7 +493,13 @@ impl Game<'_> {
|
||||
.collect();
|
||||
for conn in guests {
|
||||
let push = self.as_guest(&mut host, conn, |g| {
|
||||
g.explosion_hurt_player(center, radius, damage_type, exploding)
|
||||
g.explosion_hurt_player(
|
||||
center,
|
||||
radius,
|
||||
damage_type,
|
||||
exploding,
|
||||
attacker.as_ref(),
|
||||
)
|
||||
});
|
||||
if let Some(Some(push)) = push {
|
||||
pushed.push((conn, push));
|
||||
@@ -486,10 +544,9 @@ impl Game<'_> {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
// A projectile's owner (`getIndirectSourceEntity`), else
|
||||
// the exploding mob itself.
|
||||
attacker: self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
attacker: attacker
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
@@ -592,14 +649,10 @@ impl Game<'_> {
|
||||
self.explosion_breaks_decorations(center, diameter, interaction);
|
||||
pushed
|
||||
}
|
||||
/// `blast_spares_decorations` for the blast going off now: the mob that
|
||||
/// owns its projectile or explodes itself is the source's entity.
|
||||
/// `blast_spares_decorations` for the blast going off now: its causing
|
||||
/// mob (`blast_attacker`) is the source's entity.
|
||||
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
|
||||
let by_mob = self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(self.combat.explosion_source)
|
||||
.is_some();
|
||||
let by_mob = self.blast_attacker().is_some();
|
||||
blast_spares_decorations(self.ui.rules.bool("mob_griefing"), interaction, by_mob)
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for paintings, cushions and armour
|
||||
@@ -681,14 +734,11 @@ impl Game<'_> {
|
||||
interaction: BlockInteraction,
|
||||
fire: bool,
|
||||
) -> Result<()> {
|
||||
self.server_explode_as(
|
||||
center,
|
||||
radius,
|
||||
interaction,
|
||||
fire,
|
||||
"minecraft:explosion",
|
||||
None,
|
||||
)
|
||||
let damage_type = super::creatures::damage_type(
|
||||
horizon_client::mobs::DamageKind::Explosion,
|
||||
self.blast_attacker().is_some(),
|
||||
);
|
||||
self.server_explode_as(center, radius, interaction, fire, damage_type, None)
|
||||
}
|
||||
/// `server_explode` with the damage type the player takes and a block
|
||||
/// whose explosion resistance the damage calculator overrides.
|
||||
@@ -1003,6 +1053,69 @@ mod tests {
|
||||
assert!(blast_spares_decorations(false, Destroy, true));
|
||||
}
|
||||
|
||||
/// The type a blast hurts with and the attacker its death message names
|
||||
/// come from one cause (`DamageSources.explosion`): `player_explosion`
|
||||
/// "blown up by" that mob, or `explosion` with no one when it has none.
|
||||
fn blast_hurt(
|
||||
owner: Option<u32>,
|
||||
exploding: Option<u32>,
|
||||
live: &[(u32, Text)],
|
||||
exploded: &[(u32, Text)],
|
||||
) -> (&'static str, Option<(u32, Text)>) {
|
||||
let find = |id| live.iter().find(|(m, _)| *m == id).map(|(_, n)| n.clone());
|
||||
let cause = blast_cause(owner, exploding, find, exploded);
|
||||
let kind = horizon_client::mobs::DamageKind::Explosion;
|
||||
(
|
||||
super::super::creatures::damage_type(kind, cause.is_some()),
|
||||
cause,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caused_blast_names_its_cause_under_player_explosion() {
|
||||
let creeper = Text::translate("entity.minecraft.creeper", &[]);
|
||||
let ghast = Text::translate("entity.minecraft.ghast", &[]);
|
||||
// A creeper's blast: it left the mob list, its event named it.
|
||||
assert_eq!(
|
||||
blast_hurt(None, Some(7), &[], &[(7, creeper.clone())]),
|
||||
("minecraft:player_explosion", Some((7, creeper)))
|
||||
);
|
||||
// A ghast's fireball: its owner is still in the level.
|
||||
assert_eq!(
|
||||
blast_hurt(Some(3), None, &[(3, ghast.clone())], &[]),
|
||||
("minecraft:player_explosion", Some((3, ghast.clone())))
|
||||
);
|
||||
// The ghast was removed before its fireball landed: no owner.
|
||||
assert_eq!(
|
||||
blast_hurt(Some(3), None, &[], &[]),
|
||||
("minecraft:explosion", None)
|
||||
);
|
||||
// Nothing caused it.
|
||||
assert_eq!(
|
||||
blast_hurt(None, None, &[], &[]),
|
||||
("minecraft:explosion", None)
|
||||
);
|
||||
// A projectile's owner wins over the exploding mob, and a removed
|
||||
// owner gives no cause even when a mob is exploding.
|
||||
let zombie = Text::translate("entity.minecraft.zombie", &[]);
|
||||
let live = [(3, ghast.clone()), (5, zombie.clone())];
|
||||
assert_eq!(
|
||||
blast_hurt(Some(3), Some(5), &live, &[]),
|
||||
("minecraft:player_explosion", Some((3, ghast)))
|
||||
);
|
||||
assert_eq!(
|
||||
blast_hurt(Some(4), Some(5), &live, &[]),
|
||||
("minecraft:explosion", None)
|
||||
);
|
||||
// A mob still in the level is named as it is now, before its name
|
||||
// in the exploded list.
|
||||
let named = Text::literal("Bob".to_owned());
|
||||
assert_eq!(
|
||||
blast_hurt(None, Some(5), &live, &[(5, named)]),
|
||||
("minecraft:player_explosion", Some((5, zombie)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_pos_hash_matches_vec3i() {
|
||||
// Vec3i.hashCode: (y + z * 31) * 31 + x
|
||||
|
||||
@@ -76,8 +76,8 @@ impl Game<'_> {
|
||||
(cps, self.extras.mouse)
|
||||
}
|
||||
/// Auto-reconnect: a connection to `address` as `name` starts.
|
||||
pub(super) fn extras_connecting(&mut self, address: &str, name: &str) {
|
||||
self.extras.reconnect.connecting(address, name);
|
||||
pub(super) fn extras_connecting(&mut self, address: &str, name: &str, lan: bool) {
|
||||
self.extras.reconnect.connecting(address, name, lan);
|
||||
}
|
||||
/// The server this game connected to last.
|
||||
pub(super) fn server_address(&self) -> Option<&str> {
|
||||
@@ -124,7 +124,7 @@ impl Game<'_> {
|
||||
/// (the launcher's session file is gone after the first read).
|
||||
pub(super) fn extras_retry(&mut self) -> horizon_client::Result<()> {
|
||||
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
|
||||
self.connect_server(&retry.address, &retry.name)?;
|
||||
self.connect_server_with_options(&retry.address, &retry.name, None, retry.lan)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ use horizon_client::{
|
||||
entity_nbt::RocketNbt,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind, Species, Who},
|
||||
mobs::{Damage, Species, Who},
|
||||
particles::{FireworkExplosion, ParticleOptions, ParticleType},
|
||||
projectiles::ProjectileWorld,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -398,14 +399,14 @@ impl Game<'_> {
|
||||
// other player in range and in sight takes the blast.
|
||||
let boosted = r.guest.filter(|_| r.attached);
|
||||
if r.attached && boosted.is_none() {
|
||||
self.rocket_hurt_player(damage);
|
||||
self.rocket_hurt_player(from, damage);
|
||||
} else {
|
||||
self.rocket_blast_player(from, damage);
|
||||
}
|
||||
if let Some(conn) = boosted
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(damage));
|
||||
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(from, damage));
|
||||
self.lan = Some(host);
|
||||
}
|
||||
// Every other player in the level (LAN guests, the players of a
|
||||
@@ -466,14 +467,7 @@ impl Game<'_> {
|
||||
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
|
||||
for &(id, amount) in hits {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.damage(
|
||||
amount,
|
||||
Damage {
|
||||
kind: DamageKind::Explosion,
|
||||
attacker,
|
||||
from: Some(at),
|
||||
},
|
||||
);
|
||||
m.damage(amount, Damage::fireworks(attacker, at));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,21 +571,30 @@ impl Game<'_> {
|
||||
})
|
||||
};
|
||||
if sees {
|
||||
self.rocket_hurt_player(blast_damage(damage, from, feet));
|
||||
self.rocket_hurt_player(from, blast_damage(damage, from, feet));
|
||||
}
|
||||
}
|
||||
/// `hurtServer` with `damageSources().fireworks`: armor absorbs it and
|
||||
/// wears (`hurtArmor`); creative and spectator players are immune.
|
||||
fn rocket_hurt_player(&mut self, amount: f32) {
|
||||
if self.creative || self.spectator() {
|
||||
/// `hurtServer` with `damageSources().fireworks` from the rocket at
|
||||
/// `from`: the rocket is the direct entity, so the player is knocked away
|
||||
/// from it and the death message names it; creative and spectator
|
||||
/// players are immune.
|
||||
fn rocket_hurt_player(&mut self, from: Vec3, amount: f32) {
|
||||
if self.creative
|
||||
|| self.spectator()
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_none_or(|p| p.health.dead_or_dying())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
|
||||
{
|
||||
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
|
||||
}
|
||||
// No mob ever owns a rocket.
|
||||
self.combat.projectile_owner = None;
|
||||
self.combat.rocket_hit = true;
|
||||
self.combat.projectile_direct =
|
||||
Some(Text::translate("entity.minecraft.firework_rocket", &[]));
|
||||
let at = [from.x, from.y, from.z];
|
||||
self.hurt_player_from(amount, Some(at), "minecraft:fireworks", Some(None));
|
||||
}
|
||||
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
|
||||
/// lit at the rocket's light probe.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Game modes beyond survival and creative (`GameType`): adventure players
|
||||
//! cannot build, spectators fly through blocks and only watch.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::command::args::GameMode;
|
||||
use horizon_client::{collisions::SceneCollisions, command::args::GameMode};
|
||||
use horizon_world::world_movement::in_range_of_ground;
|
||||
|
||||
pub(super) fn mode_id_of(mode: GameMode) -> i32 {
|
||||
match mode {
|
||||
@@ -19,6 +20,14 @@ pub(super) fn mode_from_id(id: i32) -> GameMode {
|
||||
_ => GameMode::Survival,
|
||||
}
|
||||
}
|
||||
/// `ChunkMap.skipPlayer`: a player is skipped when `player.isSpectator() &&
|
||||
/// !level.getGameRules().getBoolean(SPECTATORS_GENERATE_CHUNKS)`. A skipped
|
||||
/// player holds no distance-manager ticket and streams no chunks around
|
||||
/// itself, but still receives the chunks other players load (chunk sending
|
||||
/// is untouched).
|
||||
pub(super) fn loads_chunks(spectators_generate_chunks: bool, spectator: bool) -> bool {
|
||||
!spectator || spectators_generate_chunks
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn spectator(&self) -> bool {
|
||||
self.mode == GameMode::Spectator
|
||||
@@ -36,8 +45,9 @@ impl Game<'_> {
|
||||
/// `ServerPlayer.setGameMode` and `GameType.updatePlayerAbilities`.
|
||||
pub(super) fn set_mode(&mut self, mode: GameMode) {
|
||||
let was_spectator = self.spectator();
|
||||
let changed = mode != self.mode;
|
||||
// MultiPlayerGameMode.setLocalMode: the mode it leaves.
|
||||
if mode != self.mode {
|
||||
if changed {
|
||||
self.previous_mode = Some(self.mode);
|
||||
}
|
||||
self.mode = mode;
|
||||
@@ -67,6 +77,28 @@ impl Game<'_> {
|
||||
p.flying = true;
|
||||
}
|
||||
}
|
||||
// ServerPlayerGameMode.changeGameModeForPlayer: a player left flying
|
||||
// by the change lands when the ground is within a block.
|
||||
let flying = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| p.flying)
|
||||
.map(|p| p.bounds());
|
||||
if changed
|
||||
&& mode != GameMode::Spectator
|
||||
&& let Some(bounds) = flying
|
||||
{
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if in_range_of_ground(&mut scene, bounds)
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
{
|
||||
p.flying = false;
|
||||
}
|
||||
}
|
||||
if mode == GameMode::Spectator && self.screen == Screen::Inventory {
|
||||
self.screen = Screen::Playing;
|
||||
}
|
||||
@@ -186,3 +218,24 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::loads_chunks;
|
||||
|
||||
/// `ChunkMap.skipPlayer`: only a spectator with `spectators_generate_chunks`
|
||||
/// off stops loading chunks around itself.
|
||||
#[test]
|
||||
fn a_skipped_spectator_loads_no_chunks() {
|
||||
assert!(!loads_chunks(false, true), "spectator, rule off");
|
||||
assert!(loads_chunks(true, true), "spectator, rule on");
|
||||
assert!(
|
||||
loads_chunks(false, false),
|
||||
"survival, creative or adventure"
|
||||
);
|
||||
assert!(
|
||||
loads_chunks(true, false),
|
||||
"survival, creative or adventure, rule on"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -569,6 +569,16 @@ impl Game<'_> {
|
||||
pub(super) fn attack_item_info(&self) -> Option<&horizon_client::items::ItemInfo> {
|
||||
self.items().get(self.attack_item.as_deref()?)
|
||||
}
|
||||
|
||||
/// `Player.attack`: the attack damage attribute, with the held weapon's
|
||||
/// and the effects' modifiers on it.
|
||||
pub(super) fn attack_damage(&mut self) -> f32 {
|
||||
self.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:attack_damage").ok())
|
||||
.unwrap_or(1.0) as f32
|
||||
}
|
||||
|
||||
/// `evaluateWhichHandsToRender`: which of the main and off hand show.
|
||||
pub(super) fn hands_to_render(&self) -> [bool; 2] {
|
||||
let held = |slot| {
|
||||
|
||||
@@ -577,6 +577,8 @@ impl Game<'_> {
|
||||
ready: &Sender<Ready>,
|
||||
opening: Opening,
|
||||
) -> Result<()> {
|
||||
// MinecraftServer.loadLevel's start: nothing is loaded yet.
|
||||
self.native_server_about_to_start(false);
|
||||
self.native_lifecycle_open_started();
|
||||
// `load_world`, with the world `serve` started preparing.
|
||||
self.join_world_rest()?;
|
||||
@@ -672,6 +674,8 @@ impl Game<'_> {
|
||||
fn stop_integrated(&mut self) -> Result<()> {
|
||||
// The close request: lifecycle observation stops.
|
||||
self.native_lifecycle.begin_close();
|
||||
// MinecraftServer.stopServer's start: everyone is still here.
|
||||
self.native_server_stopping(false);
|
||||
// IntegratedServer.stopServer: teardownPublishedState.
|
||||
if let Some(s) = self.serving.take() {
|
||||
s.flags.published.store(0, Ordering::Relaxed);
|
||||
@@ -956,7 +960,8 @@ impl Game<'_> {
|
||||
.find(|g| g.uuid == owner)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
host.hub.set_game_mode(conn, mode);
|
||||
host.hub
|
||||
.set_game_mode(conn, mode, &self.world, Some(self.collision));
|
||||
}
|
||||
}
|
||||
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
|
||||
|
||||
@@ -22,6 +22,9 @@ pub(super) struct Env<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
}
|
||||
impl ItemEnv for Env<'_, '_> {
|
||||
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
|
||||
self.game.stations().enchantments.in_tag(tag, enchantment)
|
||||
}
|
||||
fn potion_effect_color(&self, contents: &Value) -> Option<i32> {
|
||||
// `PotionContents.getColorOr`: the effect mix when any effect is visible.
|
||||
let id = contents["potion"].as_str();
|
||||
|
||||
@@ -16,6 +16,7 @@ use horizon_client::{
|
||||
},
|
||||
mobs::{Mob, Species, Who, leash::Holder},
|
||||
text::Text,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{
|
||||
@@ -23,6 +24,7 @@ use horizon_protocol::{
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
@@ -482,7 +484,7 @@ fn mob_meta(
|
||||
hub.effect_meta(
|
||||
(m.extra.effects.iter())
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.map(|e| (e.0, false)),
|
||||
.map(|e| (e.0, m.extra.ambient_effects.contains(&e.0))),
|
||||
),
|
||||
);
|
||||
defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new())));
|
||||
@@ -1127,7 +1129,11 @@ pub const HELP: &[&str] = &[
|
||||
/// (setting a mode does nothing when it is already theirs). A world open to
|
||||
/// LAN is an integrated server, which leaves out the players who have game
|
||||
/// master permission; a dedicated server does not.
|
||||
fn enforce_guest_game_mode(host: &mut LanHost) {
|
||||
fn enforce_guest_game_mode(
|
||||
host: &mut LanHost,
|
||||
world: &World,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
) {
|
||||
if !host.hub.config.force_game_mode {
|
||||
return;
|
||||
}
|
||||
@@ -1141,7 +1147,7 @@ fn enforce_guest_game_mode(host: &mut LanHost) {
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in stale {
|
||||
host.hub.set_game_mode(conn, mode);
|
||||
host.hub.set_game_mode(conn, mode, world, Some(collision));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1582,7 +1588,7 @@ impl Game<'_> {
|
||||
host.hub.config.default_game_mode = mode;
|
||||
// `setWorldGameType` and `DefaultGameModeCommands.setMode`: a
|
||||
// world that forces its mode puts the guests online in it.
|
||||
enforce_guest_game_mode(host);
|
||||
enforce_guest_game_mode(host, &self.world, self.collision);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1600,7 +1606,7 @@ impl Game<'_> {
|
||||
host.settings.force_game_mode = force;
|
||||
host.hub.config.guest_commands = effective;
|
||||
host.hub.config.force_game_mode = force;
|
||||
enforce_guest_game_mode(host);
|
||||
enforce_guest_game_mode(host, &self.world, self.collision);
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
}
|
||||
@@ -2407,11 +2413,12 @@ impl Game<'_> {
|
||||
// covers what the player tracks (`ChunkTrackingView`, within its view
|
||||
// distance + 1) and those chunks' lit neighbours.
|
||||
let radius = host.hub.config.view_distance + 1;
|
||||
let rule = self.ui.rules.bool("spectators_generate_chunks");
|
||||
self.streaming.extra = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| host.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator()))
|
||||
.map(|g| (g.chunk(), radius))
|
||||
.chain(
|
||||
self.arrival_chunks(self.dim)
|
||||
|
||||
@@ -449,6 +449,12 @@ impl Game<'_> {
|
||||
self.right = play.using.is_some() || self.using.is_some();
|
||||
// The guest's vehicle (Entity.vehicle) for its turn.
|
||||
let riding = std::mem::replace(&mut self.riding, g.vehicle);
|
||||
// `Entity.isPassenger()` for that turn, from the same vehicle: the
|
||||
// guest's body keeps no swim (`Entity.updateSwimming` needs it false).
|
||||
// Taken from `g.vehicle` each turn, so boarding and getting out land.
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.is_passenger = g.vehicle.is_some();
|
||||
}
|
||||
let host_station = self.station.take();
|
||||
// The guest's bed, sleep counter and respawn point; the host's
|
||||
// respawn point, anchor and action bar stay theirs.
|
||||
@@ -862,7 +868,8 @@ impl Game<'_> {
|
||||
}
|
||||
if new_mode != g.game_mode {
|
||||
let conn = g.conn;
|
||||
host.hub.set_game_mode(conn, new_mode);
|
||||
host.hub
|
||||
.set_game_mode(conn, new_mode, &self.world, Some(self.collision));
|
||||
}
|
||||
// LivingEntity.checkTotemDeathProtection's entity event, to every
|
||||
// client around, the guest's own included; this window draws it too.
|
||||
@@ -2831,7 +2838,8 @@ impl Game<'_> {
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if self.level.hardcore {
|
||||
host.hub.set_game_mode(conn, SPECTATOR);
|
||||
host.hub
|
||||
.set_game_mode(conn, SPECTATOR, &self.world, Some(self.collision));
|
||||
}
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
|
||||
@@ -456,8 +456,7 @@ impl Game<'_> {
|
||||
let attribute = if spin > 0.0 {
|
||||
spin
|
||||
} else {
|
||||
1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage)
|
||||
+ self.effect_attack_bonus()
|
||||
self.attack_damage()
|
||||
};
|
||||
let base = sweep_base_damage(ratio, attribute, scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
|
||||
@@ -705,7 +705,11 @@ impl Game<'_> {
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
|
||||
let interaction = self.destroy_type("tnt_explosion_drop_decay");
|
||||
// MinecartTNT.isPrimed (fuse > -1): a cart that ran into a wall or
|
||||
// fell unprimed blows rails up like any blast.
|
||||
self.combat.rail_proof_blast = c.fuse >= 0;
|
||||
let _ = self.with_hive_emptying_blast(|g| g.server_explode(at, power, interaction, false));
|
||||
self.combat.rail_proof_blast = false;
|
||||
}
|
||||
/// `AbstractMinecart.push` from each player walking into a cart
|
||||
/// (`LivingEntity.pushEntities`): the window's player and every server
|
||||
|
||||
@@ -431,10 +431,10 @@ impl Game<'_> {
|
||||
// The stack the equipment packet (or the mob's own AI) left:
|
||||
// its components are what the item model reads, a loaded
|
||||
// crossbow's `charged_projectiles` included.
|
||||
mob.extra
|
||||
.main_hand_stack()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ItemStack::new(item, 1))
|
||||
let Some(stack) = render::held_item_stack(&mob.extra, off_hand) else {
|
||||
continue;
|
||||
};
|
||||
stack
|
||||
};
|
||||
if !local_player && horizon_client::mobs::gear::enchanted(mob, slot) {
|
||||
// The gear's enchantments are not tracked, only that it has some.
|
||||
|
||||
@@ -128,6 +128,16 @@ impl Game<'_> {
|
||||
self.enchant_single(id, MAIN, "minecraft:piercing", 1, "minecraft:crossbow");
|
||||
}
|
||||
}
|
||||
/// `Zombie` / `AbstractSkeleton` halloween pumpkin after spawn gear.
|
||||
pub(super) fn halloween_spawn_head(&mut self, id: u32) {
|
||||
let halloween = horizon_client::mobs::finalize::is_halloween();
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let mut random = self.mobs[i].random.clone();
|
||||
horizon_client::mobs::finalize::halloween_head(&mut self.mobs[i], &mut random, halloween);
|
||||
self.mobs[i].random = random;
|
||||
}
|
||||
fn enchant_slot(&mut self, id: u32, slot: usize, chance: f32, special: f32) {
|
||||
let Some(item) = self
|
||||
.mobs
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! `Mob.aiStep`'s looting loop: a mob with `canPickUpLoot` takes what lies
|
||||
//! within its box grown by `getPickupReach` (1, 0, 1) and wears or holds it
|
||||
//! when it beats what it has (`equipItemIfPossible`), dropping the replaced
|
||||
//! stack by its drop chance. The species whose `pickUpItem` does something
|
||||
//! else keep their own loops (`pandas.rs`, `piglins.rs`, `villagers.rs`,
|
||||
//! `allays.rs`, `sulfur_cubes.rs`).
|
||||
use super::{
|
||||
Game,
|
||||
pickups::{Collector, Taken},
|
||||
};
|
||||
use horizon_client::{
|
||||
entities::ItemEntity,
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Mob, Species,
|
||||
equip::{self, Facts, ItemTags, Slot},
|
||||
finalize::{
|
||||
self, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
|
||||
stored_drop_chance,
|
||||
},
|
||||
},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Mob.aiStep`'s looting block, once per mob per tick.
|
||||
pub(super) fn tick_mob_looting(&mut self) {
|
||||
if self.dropped.is_empty() || !self.ui.rules.bool("mob_griefing") {
|
||||
return;
|
||||
}
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if !m.can_pick_up_loot() || !m.alive() || m.removed {
|
||||
continue;
|
||||
}
|
||||
// `Mob.ITEM_PICKUP_REACH`.
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
let (species, baby, passenger) = (m.species(), m.is_baby(), m.extra.passenger);
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
let e = &self.dropped[j];
|
||||
if e.pickup_delay > 0 || !reach.intersects(e.bounds()) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
let stack = e.stack.clone();
|
||||
let item = &stack.item;
|
||||
let tags = ItemTags {
|
||||
spear: self.items().item_in_tag("minecraft:spears", item),
|
||||
egg: self.items().item_in_tag("minecraft:eggs", item),
|
||||
disliked_weapon: self
|
||||
.items()
|
||||
.item_in_tag("minecraft:wither_skeleton_disliked_weapons", item),
|
||||
};
|
||||
if !equip::wants_to_pick_up(species, baby, passenger, item, tags) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
// `Mob.pickUpItem`: nothing leaves the ground what it cannot use.
|
||||
let Some(taken) = self.equip_item_if_possible(i, &stack) else {
|
||||
j += 1;
|
||||
continue;
|
||||
};
|
||||
let before = self.dropped[j].clone();
|
||||
let id = self.mobs[i].id;
|
||||
self.add_pickup(Taken::Item(Box::new(before.clone())), Collector::Mob(id));
|
||||
self.mob_item_pickup_trigger(i, &before);
|
||||
let e = &mut self.dropped[j];
|
||||
e.stack.count -= taken;
|
||||
if e.stack.count == 0 {
|
||||
self.dropped.remove(j);
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mob.equipItemIfPossible`: what `slot` would take of `stack`, and how
|
||||
/// many items that takes. `None` when the mob has no use for it.
|
||||
fn equip_item_if_possible(&mut self, index: usize, stack: &ItemStack) -> Option<u32> {
|
||||
let item = &*stack.item;
|
||||
let equip_slot = self.items().get(item).and_then(|i| i.equip_slot.clone());
|
||||
let mut slot = equip::slot_for(equip_slot.as_deref())?;
|
||||
let mut replace = self.can_replace(index, stack, slot);
|
||||
// An armour piece that beats nothing it is worn with falls back to an
|
||||
// empty main hand.
|
||||
if slot.is_armor() && !replace {
|
||||
slot = Slot::MainHand;
|
||||
replace = self.mobs[index].extra.main_hand.is_none();
|
||||
}
|
||||
if !replace {
|
||||
return None;
|
||||
}
|
||||
// shortcut: a mob's equipment is one item per slot here, so a hand
|
||||
// takes one item of a stack where vanilla takes the whole stack;
|
||||
// upgrade with a per-slot count in `Extra`.
|
||||
let taken = 1;
|
||||
let m = &mut self.mobs[index];
|
||||
let old = finalize::item(m, slot.index());
|
||||
let chance = stored_drop_chance(m, slot.index());
|
||||
let old_enchantments = slot_enchantments(m, slot.index());
|
||||
// `DropChances.byEquipment` against the roll the equip makes.
|
||||
let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance;
|
||||
set_item(m, slot.index(), item);
|
||||
// The stack's enchantments come with it: a Sharpness V sword stays sharp.
|
||||
set_slot_enchantments(m, slot.index(), stack.enchantments().to_vec());
|
||||
// `setItemSlotAndDropWhenKilled`, then `persistenceRequired`.
|
||||
set_drop_chance(m, slot.index(), 2.0);
|
||||
m.persistent = true;
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if drop_old && let Some(old) = old {
|
||||
let mut dropped = ItemStack::new(&old, 1);
|
||||
dropped.set_crafting_enchantments(old_enchantments);
|
||||
self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
Some(taken)
|
||||
}
|
||||
|
||||
/// `Mob.canReplaceCurrentItem` for what `m` wears or holds in `slot`.
|
||||
fn can_replace(&self, index: usize, stack: &ItemStack, slot: Slot) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
let Some(current) = finalize::item(m, slot.index()) else {
|
||||
return true;
|
||||
};
|
||||
let species = m.species();
|
||||
// `Drowned.canReplaceCurrentItem` never parts with a nautilus shell.
|
||||
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
|
||||
return false;
|
||||
}
|
||||
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
|
||||
let new = Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
enchantments: stack.enchantments().len(),
|
||||
damage: stack.damage as i32,
|
||||
custom_name: stack.custom_name().is_some(),
|
||||
// `EnchantmentEffectComponents.PREVENT_ARMOR_CHANGE` is the Curse of
|
||||
// Binding's, the only enchantment that carries one.
|
||||
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let worn_enchantments = slot_enchantments(m, slot.index());
|
||||
let (armor, toughness, attack) = self.attributes(m, ¤t, slot);
|
||||
// shortcut: what a mob already wears keeps no damage value or name here
|
||||
// (`Extra` records neither), so a worn piece always compares as fresh;
|
||||
// upgrade with a stack per equipment slot.
|
||||
let worn = Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
enchantments: worn_enchantments.len(),
|
||||
damage: 0,
|
||||
custom_name: false,
|
||||
prevents_armor_change: worn_enchantments
|
||||
.iter()
|
||||
.any(|(id, level)| &**id == "minecraft:binding_curse" && *level > 0),
|
||||
};
|
||||
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
|
||||
(
|
||||
self.items().item_in_tag(tag, &stack.item),
|
||||
self.items().item_in_tag(tag, ¤t),
|
||||
)
|
||||
});
|
||||
equip::can_replace_current_item(&new, Some(&worn), slot, preferred)
|
||||
}
|
||||
|
||||
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
|
||||
/// attribute starts from the mob's own value. (armour, toughness, damage)
|
||||
fn attributes(&self, m: &Mob, id: &str, slot: Slot) -> (f64, f64, f64) {
|
||||
let empty: &[(String, String, f64, String, String)] = &[];
|
||||
let modifiers = self
|
||||
.items()
|
||||
.get(id)
|
||||
.map_or(empty, |i| i.modifiers.as_slice());
|
||||
(
|
||||
equip::attribute_with(modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
|
||||
equip::attribute_with(
|
||||
modifiers,
|
||||
"minecraft:armor_toughness",
|
||||
f64::from(m.kind.armor_toughness),
|
||||
slot,
|
||||
),
|
||||
equip::attribute_with(
|
||||
modifiers,
|
||||
"minecraft:attack_damage",
|
||||
f64::from(m.attrs.attack_damage),
|
||||
slot,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// `LivingEntity.onItemPickup`: the advancement of whoever threw the item,
|
||||
/// whichever mob took it.
|
||||
pub(super) fn mob_item_pickup_trigger(&mut self, index: usize, e: &ItemEntity) {
|
||||
let Some(owner) = e.thrower else {
|
||||
return;
|
||||
};
|
||||
let stack = e.stack.clone();
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.for_each_player(|g| {
|
||||
if g.acting_uuid() == owner {
|
||||
g.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", &stack, &target);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 44 adds the server starting and stopping (`native_server_about_to_start`,
|
||||
//! `native_server_stopping`).
|
||||
//!
|
||||
//! API 43 adds the server level's random (`native_level_random`).
|
||||
//!
|
||||
//! API 42 adds the item entities near a block (`native_item_entities`,
|
||||
@@ -199,7 +202,7 @@ pub(super) use horizon_world::level::flags;
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 43;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 44;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// An authoritative server committed a changed state through `Level.setBlock`.
|
||||
@@ -666,6 +669,26 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (context, phase, error_text);
|
||||
}
|
||||
/// The server is about to open its worlds: nothing of the world is
|
||||
/// loaded yet (NeoForge's `ServerAboutToStartEvent`, "called before the
|
||||
/// server begins loading anything"). Vanilla's `MinecraftServer.loadLevel`
|
||||
/// start. Server-side: the single player server thread once Play is
|
||||
/// pressed, a dedicated server once its settings are read. Each is
|
||||
/// followed by `native_world_lifecycle`'s `WorldOpenReady` or
|
||||
/// `WorldOpenFailed`. `dedicated` tells a dedicated server from the single
|
||||
/// player one. API 44.
|
||||
pub(super) fn native_server_about_to_start(&mut self, dedicated: bool) {
|
||||
let _ = dedicated;
|
||||
}
|
||||
/// The server begins an orderly stop (NeoForge's `ServerStoppingEvent`):
|
||||
/// the start of vanilla's `MinecraftServer.stopServer`, with the worlds
|
||||
/// still loaded and the players still connected and not yet saved, so a
|
||||
/// mod can save its own data or tell them. `native_world_lifecycle`'s
|
||||
/// `WorldCloseFinal` follows once the worlds are saved. A server whose loop
|
||||
/// fails stops without this (`WorldCloseFailed` reports it). API 44.
|
||||
pub(super) fn native_server_stopping(&mut self, dedicated: bool) {
|
||||
let _ = dedicated;
|
||||
}
|
||||
/// The authoritative server tick's phases: `ServerTickStart` before any
|
||||
/// level is ticked (NeoForge's `ServerTickEvent.Pre`), `ServerTickEnd`
|
||||
/// after the last (`.Post`). Read blocks with `native_server_block`, by
|
||||
@@ -730,7 +753,8 @@ impl Game<'_> {
|
||||
/// A living player's server tick ends (`ServerPlayer.tick`): once a tick
|
||||
/// for every player in the world, after their digging, item use,
|
||||
/// cooldowns, statistics and advancements. The helpers read that player.
|
||||
/// API 31.
|
||||
/// Players still tick under `/tick freeze`; a dead player doesn't, nor
|
||||
/// does one killed earlier in the same tick. API 31.
|
||||
pub(super) fn native_player_tick(&mut self) {}
|
||||
/// The player-health HUD rows, once a frame the HUD draws them
|
||||
/// (`HealthHud::layout`, not under F1): `inputs` carries the health,
|
||||
@@ -1259,9 +1283,10 @@ impl GameLevel<'_> {
|
||||
/// `flags::UPDATE_ALL` does both. Horizon sends every write to players
|
||||
/// whatever the flags: `UPDATE_CLIENTS`, `UPDATE_INVISIBLE` and
|
||||
/// `UPDATE_IMMEDIATE` change nothing yet. Any loaded position, not only
|
||||
/// the hook's; false off an authoritative server, when the block has no
|
||||
/// such property or value, or `pos` is not loaded; true without a write
|
||||
/// when the property already has `value`. Elsewhere,
|
||||
/// the hook's; true when the block changed, false off an authoritative
|
||||
/// server, when the block has no such property or value, `pos` is not
|
||||
/// loaded, or the property already has `value` (no write, as
|
||||
/// `Level.setBlock` returns false for an unchanged state). Elsewhere,
|
||||
/// `Game::native_set_block_property_with` does the same. API 33.
|
||||
pub(super) fn native_set_block_property(
|
||||
&mut self,
|
||||
@@ -1276,12 +1301,9 @@ impl GameLevel<'_> {
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let state = self.block_state(at);
|
||||
let Some(id) = with_property(self.registry, state, key, value) else {
|
||||
let Some(id) = property_change(self.registry, state, key, value) else {
|
||||
return false;
|
||||
};
|
||||
if id == state {
|
||||
return true;
|
||||
}
|
||||
let b = self.behaviours;
|
||||
b.set_block(self, ticks, at, id, flags)
|
||||
}
|
||||
@@ -1468,8 +1490,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// Sets property `key` of the block at `pos` to `value`, as a furnace's
|
||||
/// tick sets its `lit` (`Level.setBlock` with `UPDATE_ALL`: neighbours
|
||||
/// and clients are told); false when the block has no such property or
|
||||
/// value. The block entity stays. API 18.
|
||||
/// and clients are told); true when the block changed, false when the
|
||||
/// block has no such property or value, or the property already has
|
||||
/// `value` (as `Level.setBlock` for an unchanged state). The block
|
||||
/// entity stays. API 18.
|
||||
pub(super) fn native_set_block_property(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
@@ -1481,7 +1505,8 @@ impl Game<'_> {
|
||||
/// `native_set_block_property` with `Level.setBlock`'s `flags`, as a
|
||||
/// brewing stand's tick sets its bottles with `flags::UPDATE_CLIENTS`.
|
||||
/// Players get the write whatever the flags (`flags`); false when this
|
||||
/// window isn't a server. API 33.
|
||||
/// window isn't a server, and in each case `native_set_block_property`
|
||||
/// returns false. API 33.
|
||||
pub(super) fn native_set_block_property_with(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
@@ -1494,12 +1519,9 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let Some(id) = with_property(self.pack.assets.registry, state, key, value) else {
|
||||
let Some(id) = property_change(self.pack.assets.registry, state, key, value) else {
|
||||
return false;
|
||||
};
|
||||
if id == state {
|
||||
return true;
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, id, flags))
|
||||
}
|
||||
@@ -2484,6 +2506,18 @@ fn with_property(
|
||||
registry.resolve(&s).ok()
|
||||
}
|
||||
|
||||
/// The state a mod's property write changes `state` to; none when the block
|
||||
/// has no such property or value, or already has it: `LevelChunk.setBlockState`
|
||||
/// returns null for the same state, so `Level.setBlock` returns false.
|
||||
fn property_change(
|
||||
registry: &BlockRegistry,
|
||||
state: BlockStateId,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Option<BlockStateId> {
|
||||
with_property(registry, state, key, value).filter(|&id| id != state)
|
||||
}
|
||||
|
||||
/// `config/` beside `options`, made when missing.
|
||||
fn config_dir(options: &Path) -> Result<PathBuf, String> {
|
||||
let dir = options.with_file_name("config");
|
||||
@@ -2546,6 +2580,31 @@ mod tests {
|
||||
);
|
||||
assert_eq!(super::with_property(®istry, age0, "age", "9"), None);
|
||||
assert_eq!(super::with_property(®istry, age0, "lit", "true"), None);
|
||||
// What both property setters write: nothing (false) when the
|
||||
// property already has the value, as `Level.setBlock`.
|
||||
assert_eq!(super::property_change(®istry, age0, "age", "0"), None);
|
||||
assert_eq!(
|
||||
super::property_change(®istry, age0, "age", "1"),
|
||||
Some(age1)
|
||||
);
|
||||
assert_eq!(super::property_change(®istry, age0, "age", "9"), None);
|
||||
assert_eq!(super::property_change(®istry, age0, "lit", "true"), None);
|
||||
}
|
||||
/// Both property setters return false for a write that changes nothing:
|
||||
/// each goes through `property_change`, none returns true on its own.
|
||||
#[test]
|
||||
fn property_setters_refuse_no_change() {
|
||||
let source = include_str!("native_hooks.rs");
|
||||
let code = source.split("#[cfg(test)]").next().unwrap_or_default();
|
||||
for head in [
|
||||
" pub(super) fn native_set_block_property(\n &mut self,\n ticks:",
|
||||
" pub(super) fn native_set_block_property_with(",
|
||||
] {
|
||||
let body = code.split(head).nth(1).expect(head);
|
||||
let body = &body[..body.find("\n }\n").expect(head)];
|
||||
assert!(body.contains("property_change("), "{head}");
|
||||
assert!(!body.contains("return true"), "{head}");
|
||||
}
|
||||
}
|
||||
/// horizon-mixins refuses targets whose body has a `cfg` or a macro:
|
||||
/// the hook points and helpers must stay free of both. The launcher
|
||||
|
||||
@@ -436,6 +436,7 @@ impl<'a> Game<'a> {
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, true);
|
||||
self.enchant_spawn_equipment(id);
|
||||
self.halloween_spawn_head(id);
|
||||
if !persistent {
|
||||
self.spawn_jockeys(id, true);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,42 @@ impl Game<'_> {
|
||||
let mut ui = std::mem::take(&mut self.ui.options);
|
||||
ui.draw(&self.settings, batch, &self.gui.atlas, self.cursor);
|
||||
self.ui.options = ui;
|
||||
self.show_calibration(batch.height);
|
||||
}
|
||||
/// HDR calibration's test picture to the renderer, full screen while it runs: the
|
||||
/// window goes fullscreen for it and back after, if it wasn't already.
|
||||
pub(super) fn show_calibration(&mut self, h: f32) {
|
||||
let picture = (self.screen == Screen::Options)
|
||||
.then(|| self.ui.options.calibration_picture(h))
|
||||
.flatten();
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
r.set_hdr_calibration(picture.map(|(step, nits, band)| {
|
||||
horizon_client::gfx::HdrCalibration {
|
||||
step: step.number(),
|
||||
nits,
|
||||
band,
|
||||
}
|
||||
}));
|
||||
}
|
||||
let Some(window) = &self.window else {
|
||||
return;
|
||||
};
|
||||
match (picture.is_some(), self.ui.options.calibration_fullscreen) {
|
||||
(true, None) => {
|
||||
let was = window.fullscreen().is_some();
|
||||
if !was {
|
||||
window.set_fullscreen(Some(Fullscreen::Borderless(None)));
|
||||
}
|
||||
self.ui.options.calibration_fullscreen = Some(was);
|
||||
}
|
||||
(false, Some(was)) => {
|
||||
if !was {
|
||||
window.set_fullscreen(None);
|
||||
}
|
||||
self.ui.options.calibration_fullscreen = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub(super) fn options_mouse(&mut self, pressed: bool, right: bool) -> Result<()> {
|
||||
if !pressed {
|
||||
@@ -288,6 +324,8 @@ impl Game<'_> {
|
||||
self.capture(true);
|
||||
}
|
||||
self.dragging = false;
|
||||
// Closed from calibration (horizon-ctl, a lost window): out of it.
|
||||
self.show_calibration(0.0);
|
||||
}
|
||||
Event::Language(code) => {
|
||||
self.load_language(&code);
|
||||
|
||||
@@ -15,7 +15,7 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
ticks::ChunkTicks,
|
||||
};
|
||||
use horizon_world::{chunk::BlockState, level::MovingPiston, nbt::Tag, registry::BlockStateId};
|
||||
use horizon_world::{level::MovingPiston, nbt::Tag, registry::BlockStateId};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
@@ -494,7 +494,12 @@ impl Game<'_> {
|
||||
finalize = c.remove(&"horizon:finalize".into()).is_some();
|
||||
}
|
||||
let riders = self.take_mob_passengers(&mut tag);
|
||||
let mut m = entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), self.next_entity)?;
|
||||
let mut m = entity_nbt::mob_from_nbt(
|
||||
&tag,
|
||||
self.res.mob_types(),
|
||||
self.next_entity,
|
||||
Some(self.pack.assets.registry),
|
||||
)?;
|
||||
if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
horizon_client::mobs::camel::finish_load(&mut m, self.world.game_time);
|
||||
}
|
||||
@@ -534,6 +539,7 @@ impl Game<'_> {
|
||||
self.mobs.push(m);
|
||||
if finalize {
|
||||
self.enchant_spawn_equipment(id);
|
||||
self.halloween_spawn_head(id);
|
||||
}
|
||||
for rider in riders {
|
||||
if let Some(r) = self.load_mob(rider, Some(id))
|
||||
@@ -761,7 +767,12 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut tag = entity_nbt::mob_to_nbt(&self.mobs[i], uuid, self.persist.raw.get(&id));
|
||||
let mut tag = entity_nbt::mob_to_nbt(
|
||||
&self.mobs[i],
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
Some(self.pack.assets.registry),
|
||||
);
|
||||
if horizon_client::mobs::camel::is_camel(self.mobs[i].species())
|
||||
&& let Tag::Compound(c) = &mut tag
|
||||
{
|
||||
@@ -841,36 +852,10 @@ impl Game<'_> {
|
||||
}
|
||||
/// `BlockState.CODEC`: a block name or `{id, properties}` (or legacy `{Name, Properties}`).
|
||||
pub(super) fn state_from_nbt(&self, tag: &Tag) -> Option<BlockStateId> {
|
||||
let registry = self.pack.assets.registry;
|
||||
let state = match tag.get("Name") {
|
||||
Some(Tag::String(name)) => BlockState {
|
||||
name: name.to_string_lossy(),
|
||||
properties: match tag.get("Properties") {
|
||||
Some(Tag::Compound(p)) => p
|
||||
.iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Default::default(),
|
||||
},
|
||||
},
|
||||
_ => BlockState::from_nbt(tag).ok()?,
|
||||
};
|
||||
registry
|
||||
.id(&state)
|
||||
.or_else(|| registry.default_state(&state.name))
|
||||
entity_nbt::state_from_nbt(self.pack.assets.registry, tag)
|
||||
}
|
||||
pub(in crate::game) fn state_to_nbt(&self, state: BlockStateId) -> Tag {
|
||||
let registry = self.pack.assets.registry;
|
||||
match registry.state(state) {
|
||||
Some(s) if registry.default_state(&s.name) == Some(state) => {
|
||||
Tag::String(s.name.as_str().into())
|
||||
}
|
||||
Some(s) => s.to_nbt(),
|
||||
None => Tag::String("minecraft:air".into()),
|
||||
}
|
||||
entity_nbt::state_to_nbt(self.pack.assets.registry, state)
|
||||
}
|
||||
/// The entity NBT of every simulated entity in `keys`, plus the unknown
|
||||
/// entities kept for them. With `remove`, the entities leave the world
|
||||
|
||||
@@ -234,26 +234,12 @@ impl Game<'_> {
|
||||
// Nuggets come whole, anything else one at a time.
|
||||
let (taken_item, id) = (e.clone(), self.mobs[i].id);
|
||||
let item = e.stack.item.clone();
|
||||
let carried = e.stack.clone();
|
||||
let owner = e.thrower;
|
||||
self.add_pickup(
|
||||
super::pickups::Taken::Item(Box::new(taken_item)),
|
||||
super::pickups::Taken::Item(Box::new(taken_item.clone())),
|
||||
super::pickups::Collector::Mob(id),
|
||||
);
|
||||
// LivingEntity.onItemPickup, before PiglinAi.pickUpItem splits the stack.
|
||||
// The owner is the host or a guest: whoever it is gets the trigger.
|
||||
if let Some(owner) = owner {
|
||||
let target = self.mob_entity(&self.mobs[i]);
|
||||
self.for_each_player(|g| {
|
||||
if g.acting_uuid() == owner {
|
||||
g.trigger_item_entity(
|
||||
"minecraft:thrown_item_picked_up_by_entity",
|
||||
&carried,
|
||||
&target,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
self.mob_item_pickup_trigger(i, &taken_item);
|
||||
let stack = if &*item == "minecraft:gold_nugget" {
|
||||
self.dropped.remove(j).stack
|
||||
} else {
|
||||
|
||||
@@ -56,7 +56,14 @@ enum Victim {
|
||||
/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects
|
||||
/// (at the lingering item's 0.25 duration scale) and who it has affected.
|
||||
pub(super) struct PotionCloud {
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
effects: Vec<(&'static str, i32, i32)>,
|
||||
/// The effects above that were added hidden (`MobEffectInstance.isVisible` false: no
|
||||
/// particles); the instances the cloud applies are copies and keep that.
|
||||
hidden: Vec<&'static str>,
|
||||
/// The effects above that were ambient (`MobEffectInstance.isAmbient`).
|
||||
ambient: Vec<&'static str>,
|
||||
/// `AreaEffectCloud.radiusPerTick`: `-radius / duration` when it was made.
|
||||
radius_per_tick: f32,
|
||||
/// The potion's colour (`PotionContents.getColor`).
|
||||
color: u32,
|
||||
/// Each victim and the tick it can be affected again.
|
||||
@@ -989,8 +996,7 @@ impl Game<'_> {
|
||||
if waiting {
|
||||
return true;
|
||||
}
|
||||
// setRadiusPerTick(-radius / duration).
|
||||
c.radius += -3.0 / 600.0;
|
||||
c.radius += potion.radius_per_tick;
|
||||
if c.radius < 0.5 {
|
||||
return false;
|
||||
}
|
||||
@@ -1007,7 +1013,7 @@ impl Game<'_> {
|
||||
for p in players.iter().filter(|p| p.bounds.intersects(area)) {
|
||||
inside.push((Victim::Player(p.who), p.position.x, p.position.z));
|
||||
}
|
||||
let effects = potion.effects;
|
||||
let effects = potion.effects.clone();
|
||||
let tags = &self.stations().enchantments.entity_tags;
|
||||
// Only mobs that at least one of the cloud's effects can affect are
|
||||
// taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so
|
||||
@@ -1029,7 +1035,13 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
potion.victims.push((victim, c.age + REAPPLICATION_DELAY));
|
||||
self.cloud_affect(victim, effects, c.at, &potion.source);
|
||||
self.cloud_affect(
|
||||
victim,
|
||||
&effects,
|
||||
(&potion.hidden, &potion.ambient),
|
||||
c.at,
|
||||
&potion.source,
|
||||
);
|
||||
// setRadiusOnUse(-0.5).
|
||||
c.radius -= 0.5;
|
||||
if c.radius < 0.5 {
|
||||
@@ -1038,17 +1050,55 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `Creeper.spawnLingeringCloud`: an `AreaEffectCloud` of radius 2.5, wait time 10, duration 300,
|
||||
/// shrinking by `-radius / duration` a tick and by 0.5 for each entity it affects, applying
|
||||
/// the creeper's effects at 0.25 of their remaining duration.
|
||||
pub(super) fn spawn_creeper_cloud(
|
||||
&mut self,
|
||||
at: [f64; 3],
|
||||
effects: Vec<(&'static str, i32, i32)>,
|
||||
hidden: Vec<&'static str>,
|
||||
ambient: Vec<&'static str>,
|
||||
color: u32,
|
||||
) {
|
||||
self.breath_clouds.push(Breath {
|
||||
id: 0,
|
||||
at,
|
||||
radius: 2.5,
|
||||
age: 0,
|
||||
duration: 300,
|
||||
from_dragon: false,
|
||||
potion: Some(PotionCloud {
|
||||
effects,
|
||||
hidden,
|
||||
ambient,
|
||||
radius_per_tick: -2.5 / 300.0,
|
||||
color,
|
||||
victims: Vec::new(),
|
||||
source: Text::translate("entity.minecraft.area_effect_cloud", &[]),
|
||||
}),
|
||||
});
|
||||
}
|
||||
/// What a cloud puts on one entity: instant effects at half strength
|
||||
/// (`applyInstantaneousEffect` with 0.5), the others for their duration
|
||||
/// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`).
|
||||
fn cloud_affect(
|
||||
&mut self,
|
||||
victim: Victim,
|
||||
effects: &'static [(&'static str, i32, i32)],
|
||||
effects: &[(&'static str, i32, i32)],
|
||||
(hidden, ambient): (&[&'static str], &[&'static str]),
|
||||
at: [f64; 3],
|
||||
source: &Text,
|
||||
) {
|
||||
let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1);
|
||||
// MobEffectInstance.withScaledDuration: floor(duration * scale), at least 1; an infinite
|
||||
// effect stays infinite.
|
||||
let scaled = |duration: i32| {
|
||||
if duration == -1 {
|
||||
-1
|
||||
} else {
|
||||
((f64::from(duration) * 0.25) as i32).max(1)
|
||||
}
|
||||
};
|
||||
match victim {
|
||||
Victim::Player(who) => self.as_fight_player(who, |g| {
|
||||
for (id, duration, amp) in effects {
|
||||
@@ -1060,7 +1110,10 @@ impl Game<'_> {
|
||||
g.apply_indirect_effect_action(a, source);
|
||||
}
|
||||
} else {
|
||||
g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp));
|
||||
let mut instance = EffectInstance::new(e.id, scaled(*duration), *amp);
|
||||
instance.visible = !hidden.contains(id);
|
||||
instance.ambient = ambient.contains(id);
|
||||
g.add_player_effect(instance);
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -1092,7 +1145,13 @@ impl Game<'_> {
|
||||
if mob_can_be_affected(tags, &m.kind.id, id)
|
||||
&& let Some(name) = mob_effect_id(id)
|
||||
{
|
||||
m.add_effect(name, scaled(*duration), *amp);
|
||||
m.add_effect_with(
|
||||
name,
|
||||
scaled(*duration),
|
||||
*amp,
|
||||
ambient.contains(id),
|
||||
!hidden.contains(id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1335,7 +1394,10 @@ impl Game<'_> {
|
||||
duration: 600,
|
||||
from_dragon: false,
|
||||
potion: Some(PotionCloud {
|
||||
effects: def.effects,
|
||||
effects: def.effects.to_vec(),
|
||||
hidden: Vec::new(),
|
||||
ambient: Vec::new(),
|
||||
radius_per_tick: -3.0 / 600.0,
|
||||
color,
|
||||
victims: Vec::new(),
|
||||
source: owner.unwrap_or_else(|| {
|
||||
@@ -1480,6 +1542,7 @@ fn client_tick_effects(effects: &mut PlayerEffects, darkness: &mut (f32, f32)) {
|
||||
mod tests {
|
||||
use super::super::sculk::darkness_blend_added;
|
||||
use super::*;
|
||||
use horizon_client::screen_effects::nausea_blend_added;
|
||||
|
||||
/// A server's client (multiplayer and the default single player) fades
|
||||
/// the darkness effect in over its 22 blend ticks, holds it, and fades
|
||||
@@ -1523,4 +1586,19 @@ mod tests {
|
||||
let infinite = EffectInstance::new("darkness", -1, 0);
|
||||
assert_eq!(darkness_blend_added(&infinite, false), (1.0, 1.0));
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleUpdateMobEffect`: a new nausea fades in
|
||||
/// when the server blends it; one sent unblended (joining, changing
|
||||
/// dimension) is at full strength at once, unless it is already within
|
||||
/// its blend-out ticks.
|
||||
#[test]
|
||||
fn a_new_nausea_starts_blended_or_at_once() {
|
||||
let e = EffectInstance::new("nausea", 600, 0);
|
||||
assert_eq!(nausea_blend_added(&e, true), (0.0, 0.0));
|
||||
assert_eq!(nausea_blend_added(&e, false), (1.0, 1.0));
|
||||
let ending = EffectInstance::new("nausea", 40, 0);
|
||||
assert_eq!(nausea_blend_added(&ending, false), (0.0, 0.0));
|
||||
let infinite = EffectInstance::new("nausea", -1, 0);
|
||||
assert_eq!(nausea_blend_added(&infinite, false), (1.0, 1.0));
|
||||
}
|
||||
}
|
||||
@@ -746,6 +746,7 @@ impl Game<'_> {
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
self.enchant_spawn_equipment(id);
|
||||
self.halloween_spawn_head(id);
|
||||
Some(id)
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ pub(super) struct RecipeUi {
|
||||
client: ClientBook,
|
||||
client_known: Option<BTreeSet<String>>,
|
||||
client_version: u64,
|
||||
/// The display a click last placed (`lastRecipe`), which Enter and Space
|
||||
/// place again; slot clicks keep it.
|
||||
last_clicked: Option<i32>,
|
||||
}
|
||||
|
||||
impl RecipeUi {
|
||||
@@ -99,6 +102,7 @@ impl RecipeUi {
|
||||
search.set_value("");
|
||||
}
|
||||
self.view_key = 0;
|
||||
self.last_clicked = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,6 +649,7 @@ impl Game<'_> {
|
||||
if !right && inside(cursor, x, y, 24.0, 24.0) {
|
||||
let shift = self.shift_down();
|
||||
if self.try_place(*entry, shift) {
|
||||
self.remember_click(*entry);
|
||||
self.close_narrow_book(book_type, &batch);
|
||||
}
|
||||
return true;
|
||||
@@ -688,6 +693,7 @@ impl Game<'_> {
|
||||
let (entry, _) = button.entries[step % button.entries.len()];
|
||||
let shift = self.shift_down();
|
||||
if self.try_place(entry, shift) {
|
||||
self.remember_click(entry);
|
||||
self.close_narrow_book(book_type, &batch);
|
||||
}
|
||||
}
|
||||
@@ -735,6 +741,48 @@ impl Game<'_> {
|
||||
}
|
||||
narrow
|
||||
}
|
||||
/// `RecipeBookComponent.mouseClicked` after a placed recipe: it becomes
|
||||
/// `lastRecipe`.
|
||||
fn remember_click(&mut self, entry: usize) {
|
||||
self.recipe_ui.last_clicked = self
|
||||
.recipe_ui
|
||||
.book
|
||||
.as_ref()
|
||||
.and_then(|b| b.entries.get(entry))
|
||||
.map(|e| e.display_id);
|
||||
}
|
||||
/// `RecipeBookComponent.keyPressed`: Enter or Space (`isSelection`) places
|
||||
/// the recipe clicked last again, key repeats included, as many as fit
|
||||
/// with Shift. The focused search box takes the key first.
|
||||
pub(super) fn recipe_book_key(&mut self, key: winit::keyboard::KeyCode) -> bool {
|
||||
use winit::keyboard::KeyCode;
|
||||
if !matches!(key, KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space) {
|
||||
return false;
|
||||
}
|
||||
let Some(book_type) = self.active_book() else {
|
||||
return false;
|
||||
};
|
||||
if !self.book_open(book_type) || self.spectator() || self.recipe_search_focused() {
|
||||
return false;
|
||||
}
|
||||
let Some(display) = self.recipe_ui.last_clicked else {
|
||||
return false;
|
||||
};
|
||||
// The craft just taken changed what is craftable.
|
||||
self.refresh_book_view(book_type);
|
||||
let Some(entry) = self
|
||||
.recipe_ui
|
||||
.book
|
||||
.as_ref()
|
||||
.and_then(|b| b.entry_of_display(display))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
self.audio
|
||||
.play("minecraft:ui.button.click", None, 0.25, 1.0);
|
||||
let shift = self.shift_down();
|
||||
self.try_place(entry, shift)
|
||||
}
|
||||
/// `RecipeBookComponent.mouseDragged`: while the search box is focused a
|
||||
/// drag moves its selection end to the pointer (`EditBox.onDrag`).
|
||||
pub(super) fn recipe_book_drag(&mut self) {
|
||||
|
||||
@@ -22,6 +22,7 @@ use horizon_client::{
|
||||
motion::{MotionWorld, PlayerMotion},
|
||||
net::{Event, Session},
|
||||
save::SaveDir,
|
||||
screen_effects,
|
||||
sprint::Keys,
|
||||
streaming::{ChunkGenerator, Proto, Streaming},
|
||||
tab_list::SPECTATOR,
|
||||
@@ -566,17 +567,28 @@ impl Game<'_> {
|
||||
address: &str,
|
||||
name: &str,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
self.connect_server_with_options(address, name, cookies, false)
|
||||
}
|
||||
/// `ConnectScreen.startConnecting`: keep LAN discovery's server kind through loading.
|
||||
pub(super) fn connect_server_with_options(
|
||||
&mut self,
|
||||
address: &str,
|
||||
name: &str,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
lan: bool,
|
||||
) -> Result<()> {
|
||||
super::world_loading::preload_server_loot(&self.options.resources);
|
||||
if self.defer_world_entry(super::PendingWorldEntry::Server {
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
cookies: cookies.clone(),
|
||||
lan,
|
||||
})? {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
if let Err(error) = self.join_server(address, name, None, cookies) {
|
||||
if let Err(error) = self.join_server(address, name, None, cookies, lan) {
|
||||
log::warn!("Horizon: could not join server: {error}");
|
||||
if self.title.is_none() {
|
||||
self.title = Some(
|
||||
@@ -602,7 +614,7 @@ impl Game<'_> {
|
||||
pipe: horizon_protocol::connection::Pipe,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
self.join_server("local", name, Some(pipe), None)
|
||||
self.join_server("local", name, Some(pipe), None, false)
|
||||
}
|
||||
fn join_server(
|
||||
&mut self,
|
||||
@@ -610,6 +622,7 @@ impl Game<'_> {
|
||||
name: &str,
|
||||
local: Option<horizon_protocol::connection::Pipe>,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
lan: bool,
|
||||
) -> Result<()> {
|
||||
self.reset_tutorial();
|
||||
self.perf.world_opening();
|
||||
@@ -647,7 +660,7 @@ impl Game<'_> {
|
||||
Session::connect_local(pipe, name, info.clone(), lang.clone(), root.clone())
|
||||
}
|
||||
None => {
|
||||
self.extras_connecting(address, name);
|
||||
self.extras_connecting(address, name, lan);
|
||||
match cookies {
|
||||
Some(cookies) => Session::connect_transferred(
|
||||
address,
|
||||
@@ -658,14 +671,21 @@ impl Game<'_> {
|
||||
cookies,
|
||||
root.clone(),
|
||||
),
|
||||
None => Session::connect(
|
||||
address,
|
||||
name,
|
||||
auth.clone(),
|
||||
info.clone(),
|
||||
lang.clone(),
|
||||
root.clone(),
|
||||
),
|
||||
None => {
|
||||
let connect = if lan {
|
||||
Session::connect_lan
|
||||
} else {
|
||||
Session::connect
|
||||
};
|
||||
connect(
|
||||
address,
|
||||
name,
|
||||
auth.clone(),
|
||||
info.clone(),
|
||||
lang.clone(),
|
||||
root.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -740,12 +760,15 @@ impl Game<'_> {
|
||||
quiet_commands: false,
|
||||
mod_net: false,
|
||||
builtin: Arc::new(builtin),
|
||||
status: lang
|
||||
.get("connect.connecting")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Connecting to the server...".into()),
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
joined: false,
|
||||
reconfiguring: None,
|
||||
logged_in: false,
|
||||
status: "Connecting to the server...".into(),
|
||||
entity_id: 0,
|
||||
camera: None,
|
||||
use_item: None,
|
||||
@@ -993,12 +1016,17 @@ impl Game<'_> {
|
||||
e.visible = p.flags & 2 != 0;
|
||||
let list = &mut self.potions.effects.list;
|
||||
match list.iter_mut().find(|x| x.id == id) {
|
||||
// forceAddEffect's copyBlendState: an updated darkness keeps its fade.
|
||||
// forceAddEffect's copyBlendState: an updated effect keeps its fade.
|
||||
Some(existing) => *existing = e,
|
||||
None => {
|
||||
// ClientboundUpdateMobEffectPacket.shouldBlend (flag 8).
|
||||
let blend = p.flags & 8 != 0;
|
||||
if id == "darkness" {
|
||||
// ClientboundUpdateMobEffectPacket.shouldBlend (flag 8).
|
||||
self.sculk.darkness = super::sculk::darkness_blend_added(&e, p.flags & 8 != 0);
|
||||
self.sculk.darkness = super::sculk::darkness_blend_added(&e, blend);
|
||||
} else if id == "nausea" {
|
||||
let (o_nausea, nausea) = screen_effects::nausea_blend_added(&e, blend);
|
||||
self.screen_fx.o_nausea = o_nausea;
|
||||
self.screen_fx.nausea = nausea;
|
||||
}
|
||||
list.push(e);
|
||||
}
|
||||
@@ -3246,6 +3274,15 @@ impl Game<'_> {
|
||||
}
|
||||
// The same packet for another entity: Entity.lerpMotion.
|
||||
C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity),
|
||||
// ClientPacketListener.handleProjectilePowerPacket: the power a
|
||||
// hurting projectile accelerates by, sent bundled with its motion.
|
||||
C::ProjectilePower(p) => {
|
||||
super::remote_projectiles::remote_projectile_power(
|
||||
&mut self.creatures.projectiles,
|
||||
p.id,
|
||||
p.power,
|
||||
);
|
||||
}
|
||||
// ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent
|
||||
// jolts the walk animation and restarts the hurt flash, and the
|
||||
// hurt player's own client plays its hurt sound (`Player.getHurtSound`:
|
||||
@@ -4972,7 +5009,9 @@ impl Game<'_> {
|
||||
self.push_piston_entities(pushes);
|
||||
self.sync_moving_pistons();
|
||||
self.tick_hand();
|
||||
self.tick_particles();
|
||||
if running {
|
||||
self.tick_particles();
|
||||
}
|
||||
self.tick_client_ambient();
|
||||
self.tick_client_effects();
|
||||
// Player.tick's sleep counter (the darkening and the wake-up fade).
|
||||
@@ -6511,12 +6550,37 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
if held_block {
|
||||
let consumable = held
|
||||
.as_ref()
|
||||
.and_then(|stack| self.items().get(&stack.item).cloned())
|
||||
.filter(|info| info.consumable.is_some());
|
||||
// ItemStack.useOn passes in adventure mode unless the item may
|
||||
// be placed on this block: useItem eats it (a block item without
|
||||
// a consumable keeps ending the click here).
|
||||
if !reacts
|
||||
&& consumable.is_some()
|
||||
&& !self.may_build()
|
||||
&& !self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let placed = if reacts {
|
||||
None
|
||||
} else {
|
||||
held.as_ref()
|
||||
.and_then(|stack| self.remote_can_place(&hit, stack))
|
||||
};
|
||||
// BlockItem.useOn: a consumable that could not be placed is used
|
||||
// (Item.use), which the server does for this same packet. CONSUME
|
||||
// does not swing, and a full player's FAIL still ends the click.
|
||||
// Adventure's can_place_on leaves the placing to the server.
|
||||
if !reacts
|
||||
&& placed.is_none()
|
||||
&& self.may_build()
|
||||
&& let Some(info) = &consumable
|
||||
{
|
||||
self.with_hand(hand, |g| g.start_eating(info));
|
||||
}
|
||||
if let Some(placed) = &placed {
|
||||
self.predict_place(placed, seq);
|
||||
self.with_hand(hand, Game::consume_held);
|
||||
@@ -7405,12 +7469,10 @@ impl Game<'_> {
|
||||
/// `causeExtraKnockback`: the 0.6 horizontal speed cut and the sprint
|
||||
/// stop. Without it the next move outruns the server's player.
|
||||
fn remote_player_attack(&mut self, mob: usize) {
|
||||
let id = self.mobs[mob].id as i32;
|
||||
if !self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.level_players.contains_key(&id))
|
||||
{
|
||||
// Java dispatches this through the target's RemotePlayer type. The
|
||||
// level_players map is profile bookkeeping and can lag the entity;
|
||||
// mobs must stay out because their hurtClient returns false.
|
||||
if self.mobs[mob].kind.id != "minecraft:player" {
|
||||
return;
|
||||
}
|
||||
// `getAttackStrengthScale(0.5)`: here the ticker is `hand_strength`'s
|
||||
@@ -8181,7 +8243,8 @@ impl Game<'_> {
|
||||
let title = horizon_client::text::Text::translate("connect.reconfiguring", &[]);
|
||||
let ((tx, ty), (bx, by)) = self.reconfigure_layout((w, h));
|
||||
batch.text_styled(atlas, &title, tx, ty, u32::MAX, true);
|
||||
let label = horizon_client::text::Text::translate("gui.disconnect", &[]);
|
||||
// CommonComponents.GUI_DISCONNECT
|
||||
let label = horizon_client::text::Text::translate("menu.disconnect", &[]);
|
||||
let active = self.ticks.saturating_sub(since) >= 600;
|
||||
let hover = horizon_client::ui::hovered(self.cursor, bx, by, 150.0, 20.0);
|
||||
horizon_client::ui::button(batch, atlas, bx, by, 150.0, 20.0, &label, active, hover);
|
||||
|
||||
@@ -155,6 +155,24 @@ pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorl
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleProjectilePowerPacket`: the power a hurting
|
||||
/// projectile (`AbstractHurtingProjectile`) accelerates by, found by id;
|
||||
/// anything else is ignored.
|
||||
pub(super) fn remote_projectile_power(projectiles: &mut [Projectile], id: i32, power: f64) {
|
||||
if let Some(p) = projectiles.iter_mut().find(|p| p.id == id as u32)
|
||||
&& matches!(
|
||||
p.kind,
|
||||
ProjectileKind::SmallFireball
|
||||
| ProjectileKind::LargeFireball { .. }
|
||||
| ProjectileKind::WitherSkull { .. }
|
||||
| ProjectileKind::WindCharge
|
||||
| ProjectileKind::BreezeWindCharge
|
||||
)
|
||||
{
|
||||
p.acceleration = power;
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractHurtingProjectile.getTrailParticle`: smoke for every one of them
|
||||
/// but the wind charges (no trail) and the dragon's fireball (its own).
|
||||
pub(super) fn has_smoke_trail(kind: &ProjectileKind) -> bool {
|
||||
@@ -776,6 +794,11 @@ mod tests {
|
||||
p
|
||||
}
|
||||
|
||||
/// The same, under the id a server tracks it by.
|
||||
fn tracked(kind: ProjectileKind, id: u32) -> Projectile {
|
||||
Projectile { id, ..thrown(kind) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thrown_item_flies_on_between_the_server_updates() {
|
||||
// A thrown egg, an ender pearl and a wind charge (#1762): the server
|
||||
@@ -810,4 +833,42 @@ mod tests {
|
||||
assert!((p.velocity.x - 1.5).abs() < 1e-9, "{p:?}");
|
||||
assert!((p.velocity.y - 0.0).abs() < 1e-9, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_power_packet_gives_a_fireball_the_power_its_next_tick_uses() {
|
||||
// ClientPacketListener.handleProjectilePowerPacket: the fireball with
|
||||
// id 12 gets the power the server sends bundled with its motion.
|
||||
let mut list = vec![tracked(ProjectileKind::LargeFireball { power: 1 }, 12)];
|
||||
remote_projectile_power(&mut list, 12, 0.5);
|
||||
assert_eq!(list[0].acceleration, 0.5);
|
||||
// AbstractHurtingProjectile.applyInertia: (v + v/|v| * 0.5) * 0.95,
|
||||
// where the default 0.1 would have made 1.52.
|
||||
remote_projectile_tick(&mut list[0], &Empty);
|
||||
assert!((list[0].velocity.x - 1.9).abs() < 1e-9, "{:?}", list[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_power_packet_ignores_an_arrow_an_unknown_id_and_a_thrown_item() {
|
||||
// Vanilla's instanceof AbstractHurtingProjectile: an arrow and a
|
||||
// thrown egg are none, and no entity is id 99.
|
||||
let mut list = vec![
|
||||
tracked(
|
||||
ProjectileKind::Arrow {
|
||||
base_damage: 2.0,
|
||||
critical: false,
|
||||
effect: None,
|
||||
},
|
||||
3,
|
||||
),
|
||||
tracked(ProjectileKind::Egg, 4),
|
||||
tracked(ProjectileKind::LargeFireball { power: 1 }, 12),
|
||||
];
|
||||
remote_projectile_power(&mut list, 3, 0.5);
|
||||
remote_projectile_power(&mut list, 4, 0.5);
|
||||
remote_projectile_power(&mut list, 99, 0.5);
|
||||
assert_eq!(
|
||||
list.iter().map(|p| p.acceleration).collect::<Vec<_>>(),
|
||||
[0.1; 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_client::{
|
||||
hud_overlays,
|
||||
screen_effects::{ScreenInputs, ScreenQuad, lightmap_brightness},
|
||||
screen_effects::{ScreenInputs, ScreenQuad, lightmap_brightness, nausea_has_effect},
|
||||
};
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
use horizon_world::{heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates};
|
||||
@@ -327,11 +327,7 @@ impl Game<'_> {
|
||||
if self.remote.is_some() && self.portals.player.cooldown > 0 {
|
||||
self.portals.player.cooldown -= 1;
|
||||
}
|
||||
let nausea = self
|
||||
.potions
|
||||
.effects
|
||||
.get("nausea")
|
||||
.map(|e| !e.ends_within(60));
|
||||
let nausea = self.potions.effects.get("nausea").map(nausea_has_effect);
|
||||
self.screen_fx.tick_spinning(nausea);
|
||||
// LocalPlayer water vision and AtmosphericFogEnvironment rain fog.
|
||||
let in_water =
|
||||
|
||||
@@ -10,7 +10,7 @@ use horizon_client::{
|
||||
},
|
||||
hud::HudBatch,
|
||||
hud_layout, hud_overlays,
|
||||
lan::guest::Guest,
|
||||
lan::{guest::Guest, playerdata::ints_uuid},
|
||||
save::{self, uuid_from_text, uuid_text},
|
||||
scoreboard::{self as sb, Comp, Scoreboard, literal},
|
||||
scoreboard_cmd::{self, Env, Holder},
|
||||
@@ -45,6 +45,27 @@ fn in_range(v: i32, (lo, hi): IntRange) -> bool {
|
||||
lo.is_none_or(|l| v >= l) && hi.is_none_or(|h| v <= h)
|
||||
}
|
||||
|
||||
/// `TamableAnimal.getTeam`: a pet's own team, else, when it is tamed, its root
|
||||
/// owner's, else none. An untamed pet has no owner to take one from.
|
||||
fn pet_team<'a>(board: &'a Scoreboard, own: &str, owner: Option<&str>) -> Option<&'a sb::Team> {
|
||||
board
|
||||
.players_team(own)
|
||||
.or_else(|| board.players_team(owner?))
|
||||
}
|
||||
/// `TamableAnimal.getRootOwner`: the owner, or that owner's own root owner
|
||||
/// while it is a tamed pet itself. `pet_owner` is the owner of the tamed pet
|
||||
/// with a UUID, if it has one.
|
||||
fn root_owner(mut owner: [i32; 4], pet_owner: impl Fn([i32; 4]) -> Option<[i32; 4]>) -> [i32; 4] {
|
||||
// shortcut: vanilla has no cycle check, this bound stops a hand-made loop.
|
||||
for _ in 0..64 {
|
||||
match pet_owner(owner) {
|
||||
Some(next) => owner = next,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
owner
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
fn scoreboard_path(&self) -> std::path::PathBuf {
|
||||
self.world_root.join("data/minecraft/scoreboard.dat")
|
||||
@@ -472,20 +493,45 @@ impl Game<'_> {
|
||||
self.remote_player_name(id)
|
||||
.map_or_else(|| uuid_text(uuid), str::to_owned)
|
||||
}
|
||||
/// `Entity.getTeam` of a mob or another player.
|
||||
/// `TamableAnimal.getTeam` of a mob or another player: its own team, else,
|
||||
/// when it is a tamed pet, its root owner's.
|
||||
pub(super) fn mob_team(&self, id: u32) -> Option<&sb::Team> {
|
||||
let uuid = self.persist.uuids.get(&id)?;
|
||||
self.score
|
||||
.board
|
||||
.players_team(&self.scoreboard_name(id, *uuid))
|
||||
let uuid = *self.persist.uuids.get(&id)?;
|
||||
let own = self.scoreboard_name(id, uuid);
|
||||
let owner = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id && m.extra.tame)
|
||||
.and_then(|m| self.root_owner_name(m.extra.owner?));
|
||||
pet_team(&self.score.board, &own, owner.as_deref())
|
||||
}
|
||||
/// `TamableAnimal.getRootOwner().getScoreboardName`: a pet's owner, through
|
||||
/// owners that are tamed pets themselves.
|
||||
fn root_owner_name(&self, owner: [i32; 4]) -> Option<String> {
|
||||
let pet_owner = |uuid: [i32; 4]| {
|
||||
let Target::Mob(pet) = self.target_by_uuid(ints_uuid(uuid))? else {
|
||||
return None;
|
||||
};
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == pet && m.extra.tame)?
|
||||
.extra
|
||||
.owner
|
||||
};
|
||||
let root = root_owner(owner, pet_owner);
|
||||
Some(match self.target_by_uuid(ints_uuid(root))? {
|
||||
Target::Player => self.acting_player_name().to_owned(),
|
||||
Target::Guest(conn) => self.command_guest(conn)?.name.clone(),
|
||||
Target::Mob(id) => self.scoreboard_name(id, root),
|
||||
_ => uuid_text(root),
|
||||
})
|
||||
}
|
||||
pub(super) fn player_team(&self) -> Option<&sb::Team> {
|
||||
self.score.board.players_team(self.acting_player_name())
|
||||
}
|
||||
/// The teams of the player and of the mobs on one (empty without teams).
|
||||
pub(super) fn mob_teams(&self) -> horizon_client::mobs::Teams<'_> {
|
||||
let board = &self.score.board;
|
||||
if board.teams.is_empty() {
|
||||
if self.score.board.teams.is_empty() {
|
||||
return Default::default();
|
||||
}
|
||||
horizon_client::mobs::Teams {
|
||||
@@ -493,10 +539,8 @@ impl Game<'_> {
|
||||
mobs: self
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.filter_map(|(id, uuid)| {
|
||||
Some((*id, board.players_team(&self.scoreboard_name(*id, *uuid))?))
|
||||
})
|
||||
.keys()
|
||||
.filter_map(|id| Some((*id, self.mob_team(*id)?)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
@@ -901,3 +945,74 @@ mod tab_list_tests {
|
||||
assert_eq!(players[1].latency, 123);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod team_tests {
|
||||
use super::*;
|
||||
|
||||
const WOLF: [i32; 4] = [1, 2, 3, 4];
|
||||
const CAT: [i32; 4] = [5, 6, 7, 8];
|
||||
const STEVE: [i32; 4] = [9, 10, 11, 12];
|
||||
|
||||
/// The player in place is on `red`, the cat on `blue`; nobody else is on a
|
||||
/// team (`Game::mob_team` names the player by profile, a mob by UUID).
|
||||
fn board() -> Scoreboard {
|
||||
let mut board = Scoreboard::default();
|
||||
board.add_team("red");
|
||||
board.add_to_team("Steve", "red");
|
||||
board.add_team("blue");
|
||||
board.add_to_team(&uuid_text(CAT), "blue");
|
||||
board
|
||||
}
|
||||
|
||||
fn team_name(team: Option<&sb::Team>) -> Option<&str> {
|
||||
team.map(|t| t.name.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tamed_pet_with_no_team_answers_its_owners() {
|
||||
let board = board();
|
||||
let wolf = uuid_text(WOLF);
|
||||
assert_eq!(
|
||||
team_name(pet_team(&board, &wolf, Some("Steve"))),
|
||||
Some("red")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pet_with_its_own_team_answers_that_one() {
|
||||
let board = board();
|
||||
let cat = uuid_text(CAT);
|
||||
assert_eq!(
|
||||
team_name(pet_team(&board, &cat, Some("Steve"))),
|
||||
Some("blue")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untamed_pet_answers_no_team() {
|
||||
let board = board();
|
||||
let wolf = uuid_text(WOLF);
|
||||
// `mob_team` names no owner for a pet that is not tamed.
|
||||
assert_eq!(pet_team(&board, &wolf, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pet_owned_by_a_pet_reaches_the_root_owner() {
|
||||
let board = board();
|
||||
// The wolf's owner is the cat, whose own owner is the player: the
|
||||
// wolf answers the player's team, not the cat's.
|
||||
let pet_owner = |pet: [i32; 4]| (pet == CAT).then_some(STEVE);
|
||||
let root = root_owner(CAT, pet_owner);
|
||||
assert_eq!(root, STEVE);
|
||||
let root_name = if root == STEVE { "Steve" } else { "nobody" };
|
||||
assert_eq!(
|
||||
team_name(pet_team(&board, &uuid_text(WOLF), Some(root_name))),
|
||||
Some("red")
|
||||
);
|
||||
// An owner loop (only `/data` can make one) stops instead of hanging.
|
||||
let looped = |uuid: [i32; 4]| Some(if uuid == WOLF { CAT } else { WOLF });
|
||||
let root = root_owner(WOLF, looped);
|
||||
assert!(root == WOLF || root == CAT);
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,7 @@ impl Game<'_> {
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
self.enchant_spawn_equipment(id);
|
||||
self.halloween_spawn_head(id);
|
||||
self.spawn_jockeys(id, true);
|
||||
Some((id, equipment))
|
||||
}
|
||||
@@ -212,7 +213,12 @@ impl Game<'_> {
|
||||
tag.insert("PersistenceRequired".into(), Tag::Byte(1));
|
||||
}
|
||||
let tag = Tag::Compound(tag);
|
||||
let mob = horizon_client::entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), 0)?;
|
||||
let mob = horizon_client::entity_nbt::mob_from_nbt(
|
||||
&tag,
|
||||
self.res.mob_types(),
|
||||
0,
|
||||
Some(self.pack.assets.registry),
|
||||
)?;
|
||||
let bounds = mob.bounds();
|
||||
planned.push(Planned::Loaded(tag, r.equipment.cloned()));
|
||||
bounds
|
||||
|
||||
@@ -307,9 +307,7 @@ impl Game<'_> {
|
||||
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 damage = self.attack_damage();
|
||||
let movement = self.known_player_movement();
|
||||
let mut hit = false;
|
||||
for i in self.stab_targets(eye, look, movement) {
|
||||
|
||||
@@ -34,6 +34,7 @@ impl StartupMenu {
|
||||
address: address.clone(),
|
||||
name: options.player_name(),
|
||||
cookies: None,
|
||||
lan: false,
|
||||
})
|
||||
} else {
|
||||
options
|
||||
@@ -131,10 +132,11 @@ impl StartupMenu {
|
||||
creative,
|
||||
create: Some(Box::new(options)),
|
||||
},
|
||||
Action::Connect { address } => PendingWorldEntry::Server {
|
||||
Action::Connect { address, lan } => PendingWorldEntry::Server {
|
||||
address,
|
||||
name: player_name,
|
||||
cookies: None,
|
||||
lan,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
@@ -874,16 +876,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_server_keeps_address_and_player_identity() {
|
||||
fn queued_server_keeps_address_player_identity_and_lan_kind() {
|
||||
let mut menu = StartupMenu::default();
|
||||
menu.queue_entry(
|
||||
Action::Connect {
|
||||
address: "localhost:25565".into(),
|
||||
lan: true,
|
||||
},
|
||||
"Guest".into(),
|
||||
);
|
||||
assert!(
|
||||
matches!(menu.pending.take(), Some(PendingWorldEntry::Server { address, name, .. })
|
||||
matches!(menu.pending.take(), Some(PendingWorldEntry::Server { address, name, lan: true, .. })
|
||||
if address == "localhost:25565" && name == "Guest")
|
||||
);
|
||||
assert!(menu.pending.take().is_none(), "an entry is consumed once");
|
||||
|
||||
@@ -114,6 +114,17 @@ impl RemoteTextDisplay {
|
||||
(((self.age - self.start) as f32 + partial) / self.duration as f32).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
/// The casters this display adds to the shadow pass: its interpolated
|
||||
/// shadow radius and strength (`DisplayRenderer.getShadowRadius` and
|
||||
/// `DisplayRenderer.getShadowStrength`), if it has any.
|
||||
pub(super) fn shadow(&self, partial: f64) -> Option<(glam::DVec3, f32, f32)> {
|
||||
horizon_client::shadows::display_caster(
|
||||
self.previous.lerp(self.position, partial),
|
||||
&self.from,
|
||||
&self.shown,
|
||||
self.progress(partial as f32),
|
||||
)
|
||||
}
|
||||
/// The entries after `Display`'s: a block state, or an item with its
|
||||
/// display context (the stack comes converted by the caller).
|
||||
pub fn show(&mut self, values: &[(u8, i32, Value)], item: Option<Option<ItemStack>>) {
|
||||
|
||||
@@ -672,8 +672,7 @@ impl Game<'_> {
|
||||
r.v1 = r.v0 + 166.0 * dv;
|
||||
batch.rect_uv(ox + SHIFT, oy, 276.0, 166.0, r, u32::MAX);
|
||||
}
|
||||
let e = &self.stations().enchantments;
|
||||
let tr = |k: &str, f: &str| e.translate(k).unwrap_or(f).to_owned();
|
||||
let tr = |k: &str, f: &str| horizon_client::lang::get_or(k, f);
|
||||
let name = custom.unwrap_or_else(|| {
|
||||
if progress {
|
||||
tr(
|
||||
@@ -897,13 +896,10 @@ impl Game<'_> {
|
||||
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
} else if deprecated {
|
||||
let text = self
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.translate("merchant.deprecated")
|
||||
.unwrap_or("Villagers restock up to two times per day.")
|
||||
.to_owned();
|
||||
let text = horizon_client::lang::get_or(
|
||||
"merchant.deprecated",
|
||||
"Villagers restock up to two times per day.",
|
||||
);
|
||||
let lines: Vec<(String, u32)> =
|
||||
text.split('\n').map(|l| (l.to_owned(), u32::MAX)).collect();
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
|
||||
@@ -192,9 +192,6 @@ impl Game<'_> {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(id) = owner {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_owner = owner;
|
||||
let result = self.with_hive_emptying_blast(|g| g.explode(at, 1.0));
|
||||
self.combat.explosion_owner = None;
|
||||
|
||||
@@ -490,6 +490,67 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `custom_model_data` and `item_model` reach the client, whose item
|
||||
/// model reads them (#4916): single player's window gets its stacks this way.
|
||||
#[test]
|
||||
fn model_components_cross_the_wire() {
|
||||
let mut components = vec![String::new(); 60];
|
||||
components[component::ITEM_MODEL as usize] = "minecraft:item_model".into();
|
||||
components[component::CUSTOM_MODEL_DATA as usize] = "minecraft:custom_model_data".into();
|
||||
let items = ["minecraft:air", "minecraft:stick"];
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".to_owned(),
|
||||
Ids::from_names(items.iter().map(|s| s.to_string()).collect()),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".to_owned(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let given = crate::item_data::command_components(
|
||||
"[item_model='minecraft:emerald',custom_model_data={floats:[0.5f],flags:[1b],\
|
||||
strings:['gold'],colors:[16711680,[0.0f,1.0f,0.0f]]}]",
|
||||
)
|
||||
.unwrap();
|
||||
let (damage, extra) = crate::item_data::decode_components(Some(&given));
|
||||
let stick = ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..ItemStack::new("minecraft:stick", 1)
|
||||
};
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| items.iter().position(|i| *i == name).unwrap_or(0) as i32,
|
||||
|_, _| None,
|
||||
&Some(stick),
|
||||
)
|
||||
.unwrap();
|
||||
let back = from_network(&wire, &builtin, &Dynamic::default());
|
||||
let data = back.data().expect("components");
|
||||
assert_eq!(
|
||||
crate::save::text(data.other("minecraft:item_model")).as_deref(),
|
||||
Some("minecraft:emerald")
|
||||
);
|
||||
let cmd = data
|
||||
.other("minecraft:custom_model_data")
|
||||
.expect("custom_model_data");
|
||||
let list = |key: &str| match cmd.get(key) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
other => panic!("{key}: {other:?}"),
|
||||
};
|
||||
assert_eq!(list("floats"), [Tag::Float(0.5f32.to_bits())]);
|
||||
assert_eq!(list("flags"), [Tag::Byte(1)]);
|
||||
assert_eq!(list("strings"), [Tag::String("gold".into())]);
|
||||
// The float triple arrives as the opaque int vanilla's codec makes of it.
|
||||
assert_eq!(
|
||||
list("colors"),
|
||||
[Tag::Int(16711680), Tag::Int(0xFF00FF00u32 as i32)]
|
||||
);
|
||||
}
|
||||
|
||||
/// An ominous bottle's `ominous_bottle_amplifier` reaches the client
|
||||
/// through the packet's bytes, so its tooltip names the Bad Omen level.
|
||||
#[test]
|
||||
|
||||
@@ -380,7 +380,9 @@ impl ServerAdvancements {
|
||||
// The result as its ItemStackSlotDisplay, the station as its item.
|
||||
let result = |w: &mut Writer| {
|
||||
w.varint(slot("minecraft:item_stack"));
|
||||
w.varint(item(r["result"]["id"].as_str().unwrap_or("minecraft:air")));
|
||||
w.varint(item(
|
||||
horizon_world::loot::recipe_result_id(&r["result"]).unwrap_or("minecraft:air"),
|
||||
));
|
||||
w.varint(r["result"]["count"].as_i64().unwrap_or(1) as i32);
|
||||
w.varint(0);
|
||||
w.varint(0);
|
||||
|
||||
@@ -144,6 +144,15 @@ impl Scanner {
|
||||
inner: Arc::new(ScanInner { games, stop }),
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn for_tests(games: Vec<LanGame>) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(ScanInner {
|
||||
games: Arc::new(Mutex::new(games)),
|
||||
stop: Arc::new(AtomicBool::new(false)),
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// The worlds announced since the scan started, in order (vanilla's
|
||||
/// list keeps them while the multiplayer screen is open).
|
||||
pub fn games(&self) -> Vec<LanGame> {
|
||||
|
||||
@@ -982,9 +982,71 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Text(name.clone()),
|
||||
));
|
||||
}
|
||||
// The model a pack or mod picks for this stack, which the client's item model reads.
|
||||
if let Some(model) = s
|
||||
.data()
|
||||
.and_then(|d| crate::save::text(d.other("minecraft:item_model")))
|
||||
{
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::ITEM_MODEL,
|
||||
horizon_protocol::item::Component::Ident(model),
|
||||
));
|
||||
}
|
||||
if let Some(t) = s
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:custom_model_data"))
|
||||
{
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::CUSTOM_MODEL_DATA,
|
||||
custom_model_data(t),
|
||||
));
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `CustomModelData.CODEC` read from its saved form, for its stream codec.
|
||||
fn custom_model_data(t: &horizon_world::nbt::Tag) -> horizon_protocol::item::Component {
|
||||
use horizon_world::nbt::Tag;
|
||||
// NbtOps writes a list of ints or bytes as an int or byte array.
|
||||
let entries = |key: &str| -> Vec<Tag> {
|
||||
match t.get(key) {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
Some(Tag::IntArray(a)) => a.iter().map(|v| Tag::Int(*v)).collect(),
|
||||
Some(Tag::ByteArray(a)) => a.iter().map(|v| Tag::Byte(*v as i8)).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
// `ARGB.as8BitChannel`.
|
||||
let channel = |v: f64| ((v as f32) * 255.0).floor() as i32 & 255;
|
||||
horizon_protocol::item::Component::CustomModelData {
|
||||
floats: entries("floats")
|
||||
.iter()
|
||||
.filter_map(|v| crate::save::float(Some(v)))
|
||||
.map(|v| v as f32)
|
||||
.collect(),
|
||||
flags: entries("flags")
|
||||
.iter()
|
||||
.filter_map(|v| crate::save::number(Some(v)))
|
||||
.map(|v| v != 0)
|
||||
.collect(),
|
||||
strings: entries("strings")
|
||||
.iter()
|
||||
.filter_map(|v| crate::save::text(Some(v)))
|
||||
.collect(),
|
||||
// `ExtraCodecs.RGB_COLOR_CODEC`: an int, or an opaque colour from three floats.
|
||||
colors: entries("colors")
|
||||
.iter()
|
||||
.filter_map(|v| match v {
|
||||
Tag::List(rgb) if rgb.len() == 3 => {
|
||||
let c = |i: usize| crate::save::float(rgb.get(i)).map(channel);
|
||||
Some(0xFF << 24 | c(0)? << 16 | c(1)? << 8 | c(2)?)
|
||||
}
|
||||
v => crate::save::number(Some(v)).map(|v| v as i32),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.isWithinBlockInteractionRange`: `eye` closer than `range` to the
|
||||
/// box of the block at `pos` (`AABB.distanceToSqr`).
|
||||
pub fn within_block_range(eye: [f64; 3], range: f64, pos: [i32; 3]) -> bool {
|
||||
|
||||
@@ -33,6 +33,7 @@ use horizon_world::{
|
||||
block_collision_scan::BlockCollisionScan,
|
||||
collision_context::CollisionContext,
|
||||
nbt::{JavaString, Tag},
|
||||
world_movement::in_range_of_ground,
|
||||
};
|
||||
use std::sync::{Arc, Mutex, atomic::Ordering};
|
||||
|
||||
@@ -3117,7 +3118,13 @@ impl Hub {
|
||||
}
|
||||
|
||||
/// Set a guest's game mode and tell everyone (ServerPlayer.setGameMode).
|
||||
pub fn set_game_mode(&mut self, conn: u32, mode: i32) {
|
||||
pub fn set_game_mode(
|
||||
&mut self,
|
||||
conn: u32,
|
||||
mode: i32,
|
||||
world: &World,
|
||||
collision: Option<&BlockCollisionRegistry<'_>>,
|
||||
) {
|
||||
let Some(g) = self.guest_mut(conn) else {
|
||||
return;
|
||||
};
|
||||
@@ -3129,6 +3136,21 @@ impl Hub {
|
||||
} else if !g.creative() {
|
||||
g.flying = false;
|
||||
}
|
||||
// ServerPlayerGameMode.changeGameModeForPlayer: a player left flying
|
||||
// by the change lands when the ground is within a block.
|
||||
if !g.spectator()
|
||||
&& g.flying
|
||||
&& let Some(binding) = collision
|
||||
{
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding,
|
||||
subject: None,
|
||||
};
|
||||
if in_range_of_ground(&mut scene, g.bounds()) {
|
||||
g.flying = false;
|
||||
}
|
||||
}
|
||||
let uuid = g.uuid;
|
||||
g.send(&C::GameEvent(play::GameEvent {
|
||||
event: game_event::CHANGE_GAME_MODE,
|
||||
|
||||
@@ -588,13 +588,26 @@ fn swap(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
|
||||
if i >= view.len() || !view.enabled(i) {
|
||||
return;
|
||||
}
|
||||
// Hotbar slot `button` (0..9) or the off hand (40).
|
||||
// Hotbar slot `button` (0..9) or the off hand (40). A chest has no
|
||||
// off-hand slot, so F swaps with `Guest.offhand` (Inventory slot 40).
|
||||
if button == 40 && view.container.is_some() {
|
||||
let (clicked, off) = (view.get(i), view.guest.offhand.clone());
|
||||
if off
|
||||
.as_ref()
|
||||
.is_some_and(|off| !view.may_place(i, off, rules))
|
||||
{
|
||||
return;
|
||||
}
|
||||
view.set(i, off);
|
||||
view.guest.offhand = clicked;
|
||||
return;
|
||||
}
|
||||
let other = match button {
|
||||
0..=8 => match &view.container {
|
||||
Some(c) => c.len() + 27 + button as usize,
|
||||
None => 36 + button as usize,
|
||||
},
|
||||
40 if view.container.is_none() => 45,
|
||||
40 => 45,
|
||||
_ => return,
|
||||
};
|
||||
if other == i {
|
||||
@@ -921,6 +934,64 @@ mod tests {
|
||||
assert_eq!(view.get(2).map(|s| s.item), Some("minecraft:stone".into()));
|
||||
}
|
||||
|
||||
struct SwapRules;
|
||||
impl Rules for SwapRules {
|
||||
fn items(&self) -> &Items {
|
||||
unreachable!("swap does not read the item catalog");
|
||||
}
|
||||
fn max_stack(&self, _: &str) -> u32 {
|
||||
64
|
||||
}
|
||||
fn armor_index(&self, _: &str) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// F (SWAP button 40) over a chest or the inventory below it swaps with
|
||||
/// the off hand, even though the menu has no off-hand slot.
|
||||
#[test]
|
||||
fn guest_chest_f_swaps_with_the_off_hand() {
|
||||
let rules = SwapRules;
|
||||
let mut g = guest();
|
||||
g.offhand = Some(ItemStack::new("minecraft:shield", 1));
|
||||
let mut chest = vec![Some(ItemStack::new("minecraft:diamond", 1)), None];
|
||||
let mut drag = None;
|
||||
let mut view = View {
|
||||
guest: &mut g,
|
||||
container: Some(&mut chest),
|
||||
shulker: false,
|
||||
hidden: &[],
|
||||
};
|
||||
click(&mut view, &mut drag, 0, 40, input::SWAP, &rules);
|
||||
assert_eq!(
|
||||
view.get(0).as_ref().map(|s| &*s.item),
|
||||
Some("minecraft:shield")
|
||||
);
|
||||
assert_eq!(
|
||||
view.guest.offhand.as_ref().map(|s| &*s.item),
|
||||
Some("minecraft:diamond")
|
||||
);
|
||||
// Empty chest slot: the off hand goes in.
|
||||
click(&mut view, &mut drag, 1, 40, input::SWAP, &rules);
|
||||
assert_eq!(
|
||||
view.get(1).as_ref().map(|s| &*s.item),
|
||||
Some("minecraft:diamond")
|
||||
);
|
||||
assert!(view.guest.offhand.is_none());
|
||||
// F over the first hotbar slot (menu slot 2 + 27).
|
||||
view.guest.offhand = Some(ItemStack::new("minecraft:oak_boat", 1));
|
||||
view.guest.inventory[0] = Some(ItemStack::new("minecraft:apple", 1));
|
||||
click(&mut view, &mut drag, 2 + 27, 40, input::SWAP, &rules);
|
||||
assert_eq!(
|
||||
view.guest.inventory[0].as_ref().map(|s| &*s.item),
|
||||
Some("minecraft:oak_boat")
|
||||
);
|
||||
assert_eq!(
|
||||
view.guest.offhand.as_ref().map(|s| &*s.item),
|
||||
Some("minecraft:apple")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guest_shulker_box_menu_refuses_shulker_boxes() {
|
||||
let Some(rules) = rules() else {
|
||||
|
||||
@@ -192,6 +192,9 @@ impl Replay {
|
||||
mut run: impl FnMut(&mut PlayerMotion, Keys, bool) -> crate::Result<()>,
|
||||
) -> Option<Over> {
|
||||
let mut over = Over::default();
|
||||
// A tick of this catch-up already counted: the client has been displaced by its lie, so a
|
||||
// tick that agrees from there must not replace the setback point (#5132).
|
||||
let mut counted = false;
|
||||
let mut changes = record.changes_since(self.applied)?.peekable();
|
||||
let new = usize::try_from(ended.saturating_sub(self.played)).ok()?;
|
||||
for tick in record.since(self.played)?.take(new) {
|
||||
@@ -215,9 +218,14 @@ impl Replay {
|
||||
|| record.unseen_near(tick.seen, |block| near(at, block, slack))
|
||||
};
|
||||
if offset <= AGREED {
|
||||
(self.agreed, self.drift) = (self.said, 0);
|
||||
if !counted {
|
||||
self.agreed = self.said;
|
||||
}
|
||||
self.drift = 0;
|
||||
} else if offset <= tolerance {
|
||||
self.agreed = self.said;
|
||||
if !counted {
|
||||
self.agreed = self.said;
|
||||
}
|
||||
self.drift = self.drift.saturating_sub(1);
|
||||
} else if self.drift == 0 && unseen() {
|
||||
self.excuse();
|
||||
@@ -225,6 +233,7 @@ impl Replay {
|
||||
self.drift -= 1;
|
||||
self.agreed = self.said;
|
||||
} else {
|
||||
counted = true;
|
||||
over.ticks += 1;
|
||||
over.worst = over.worst.max(offset);
|
||||
over.weight += (offset / tolerance).min(MOST_WEIGHT) as f32;
|
||||
@@ -474,6 +483,9 @@ mod tests {
|
||||
/// From tick `.0` on, the ticks come in a burst that the server only plays at tick `.1`,
|
||||
/// when the client accepts a teleport back to where the burst began.
|
||||
teleport: Option<(usize, usize)>,
|
||||
/// Ticks `.0 .. .1` arrive together: the server plays them all at tick `.1`, in one
|
||||
/// catch-up, as it does when its tick lags the client's.
|
||||
burst: Option<(usize, usize)>,
|
||||
/// Blocks set in the scene after the floor.
|
||||
blocks: Vec<([i32; 3], &'static str)>,
|
||||
}
|
||||
@@ -487,6 +499,7 @@ mod tests {
|
||||
pitch: 0.0,
|
||||
glider: false,
|
||||
teleport: None,
|
||||
burst: None,
|
||||
blocks: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -564,6 +577,7 @@ mod tests {
|
||||
let mut replay = Replay::new(&server, &record.take_restart().unwrap());
|
||||
let mut played = Vec::new();
|
||||
let run_teleport = run.teleport;
|
||||
let run_burst = run.burst;
|
||||
let mut back = start;
|
||||
for (i, (input, yaw)) in run.script.into_iter().enumerate() {
|
||||
let tick = i as u64;
|
||||
@@ -590,6 +604,10 @@ mod tests {
|
||||
let ground = u8::from(client.ground.on_ground);
|
||||
record.moved(Some([p.x, p.y, p.z]), Some((yaw, run.pitch)), ground);
|
||||
record.tick_end(input, client.sprinting);
|
||||
// A burst: the server hears these ticks now and plays them together at the last one.
|
||||
if run_burst.is_some_and(|(from, to)| (from..to).contains(&i)) {
|
||||
continue;
|
||||
}
|
||||
let mut run = |body: &mut PlayerMotion, keys, sprinting| {
|
||||
let mut result = Ok(());
|
||||
world(body, &mut |w| result = w.replayed_tick(keys, sprinting));
|
||||
@@ -697,6 +715,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ticks that arrive together when the server's tick lags the client's are played in one
|
||||
/// catch-up: a lie on the first makes the replay stand at the client's spot, and the honest
|
||||
/// tick after it must not become the setback point (#5132).
|
||||
#[test]
|
||||
fn a_burst_keeps_the_setback_before_its_lie() {
|
||||
let Some(steps) = steps_in(
|
||||
Run {
|
||||
burst: Some((20, 21)),
|
||||
..Run::walk()
|
||||
},
|
||||
|i, _, honest| {
|
||||
if i == 20 {
|
||||
honest.add_vector(Vec3::new(3.0, 0.0, 0.0))
|
||||
} else {
|
||||
honest
|
||||
}
|
||||
},
|
||||
None,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
// Ticks 0-19 each play on their own; tick 20's lie arrives with tick 21, so the burst's
|
||||
// single step is the 21st: the lie counts once, and the setback point stays where the walk
|
||||
// agreed, before it.
|
||||
assert_eq!(
|
||||
steps.len(),
|
||||
script().len() - 1,
|
||||
"one catch-up for the burst"
|
||||
);
|
||||
let burst = &steps[20];
|
||||
assert_eq!(burst.over.ticks, 1, "{:?}", burst.over);
|
||||
assert!(burst.over.worst > 2.9, "{:?}", burst.over);
|
||||
assert_eq!(
|
||||
burst.agreed, steps[19].said,
|
||||
"the setback point must be the last honest spot"
|
||||
);
|
||||
}
|
||||
|
||||
/// A push the server never sent (another entity's): excused, the replay takes the client's
|
||||
/// velocity, is back within the tolerance in a few ticks, and nothing counts.
|
||||
#[test]
|
||||
@@ -735,6 +791,7 @@ mod tests {
|
||||
pitch: 10.0,
|
||||
glider: true,
|
||||
teleport: None,
|
||||
burst: None,
|
||||
blocks: Vec::new(),
|
||||
};
|
||||
let Some(steps) = steps_in(run, |_, _, honest| honest, None) else {
|
||||
|
||||
@@ -158,7 +158,9 @@ pub fn update_recipes(
|
||||
None => w.varint(1),
|
||||
}
|
||||
w.varint(item_stack);
|
||||
w.varint(item(r["result"]["id"].as_str().unwrap_or("minecraft:air")));
|
||||
w.varint(item(
|
||||
horizon_world::loot::recipe_result_id(&r["result"]).unwrap_or("minecraft:air"),
|
||||
));
|
||||
w.varint(r["result"]["count"].as_i64().unwrap_or(1) as i32);
|
||||
w.varint(0); // no components added
|
||||
w.varint(0); // nor removed
|
||||
|
||||
@@ -9,15 +9,15 @@ pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_entities, block_entity_anim,
|
||||
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, clouds,
|
||||
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hud, hud_layout,
|
||||
hud_overlays, inventory_effects, java_text, jigsaw_block, level_loading, light, lod, logging,
|
||||
menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit,
|
||||
options_file, options_fixes, options_ui, overlay, pack_screen, packs, par, particles,
|
||||
poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects,
|
||||
sculk, section_mask, settings, shader_pack_list, signs, skins, sound_engine, sound_events,
|
||||
sound_preview, spans, split_layout, sprites, streaming, structure_block, tab_list, telemetry,
|
||||
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
|
||||
world, worldborder, zip,
|
||||
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, level_loading, light,
|
||||
lod, logging, menu_blur, mesher, minimap, mipmap, mod_list, model_library, models,
|
||||
multiline_edit, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
|
||||
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
|
||||
screen_effects, sculk, section_mask, settings, shader_pack_list, signs, skins, sound_engine,
|
||||
sound_events, sound_preview, spans, split_layout, sprites, streaming, structure_block,
|
||||
tab_list, telemetry, test_block, test_instance_block, testgen, title_edition, trims, ui, vault,
|
||||
weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
|
||||
@@ -33,6 +33,7 @@ pub mod doors;
|
||||
pub mod drops;
|
||||
pub mod enderman;
|
||||
pub mod equine;
|
||||
pub mod equip;
|
||||
pub mod farm;
|
||||
pub mod finalize;
|
||||
pub mod fox;
|
||||
@@ -639,6 +640,16 @@ impl Damage {
|
||||
from: Some(from),
|
||||
}
|
||||
}
|
||||
/// `DamageSources.fireworks(rocket, owner)`: `attacker` is the owner
|
||||
/// (none for a rocket nobody fired), `from` the rocket's position, which
|
||||
/// the knockback pushes away from (`calculateHorizontalHurtKnockbackDirection`).
|
||||
pub fn fireworks(attacker: Option<Who>, from: [f64; 3]) -> Self {
|
||||
Self {
|
||||
kind: DamageKind::Fireworks,
|
||||
attacker,
|
||||
from: Some(from),
|
||||
}
|
||||
}
|
||||
pub fn mob(id: u32, from: [f64; 3]) -> Self {
|
||||
Self {
|
||||
kind: DamageKind::MobAttack,
|
||||
@@ -921,6 +932,9 @@ pub struct Extra {
|
||||
pub effects: Vec<(&'static str, i32, i32)>,
|
||||
/// The effects above given with `hideParticles`: no swirls for them.
|
||||
pub hidden_effects: Vec<&'static str>,
|
||||
/// The effects above that are ambient (`MobEffectInstance.isAmbient`: a beacon's), whose swirls
|
||||
/// are faint.
|
||||
pub ambient_effects: Vec<&'static str>,
|
||||
/// `LivingEntity.DATA_EFFECT_PARTICLES` and `DATA_EFFECT_AMBIENCE_ID` as a
|
||||
/// server gave them: the swirls around it, and whether they are all faint.
|
||||
pub effect_particles: Vec<crate::particles::ParticleOptions>,
|
||||
@@ -1341,6 +1355,17 @@ pub enum MobEvent {
|
||||
/// The centre, the radius and the kind of the mob blowing up (it may be
|
||||
/// gone from the list by the time the blast is handled).
|
||||
Explode([f64; 3], f32, Species),
|
||||
/// `Creeper.spawnLingeringCloud`, right after its `Explode`: an `AreaEffectCloud` at the
|
||||
/// creeper with its active effects `(id, remaining ticks, amplifier)`, in their mixed colour.
|
||||
LingeringCloud {
|
||||
at: [f64; 3],
|
||||
effects: Vec<(&'static str, i32, i32)>,
|
||||
/// The effects among them added hidden (`hideParticles`).
|
||||
hidden: Vec<&'static str>,
|
||||
/// The effects among them that are ambient.
|
||||
ambient: Vec<&'static str>,
|
||||
color: u32,
|
||||
},
|
||||
/// Melee or ranged damage to another mob.
|
||||
AttackMob {
|
||||
target: u32,
|
||||
@@ -1990,22 +2015,34 @@ impl PendingPushes {
|
||||
self.by_mob.entry(id).or_default().push(v);
|
||||
}
|
||||
}
|
||||
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
|
||||
/// `ServerLevel.sendBlockUpdated`: for each write that changed a block's collision
|
||||
/// shape, a mob whose path runs near it re-paths (`PathNavigation.shouldRecomputePath`).
|
||||
pub fn blocks_changed(m: &mut Mob, w: &MobWorld, writes: &[((i32, i32, i32), u32, u32)]) {
|
||||
/// The writes whose shape changed are found once per tick ([`collision_changes`]).
|
||||
pub fn blocks_changed(m: &mut Mob, changed: &[[i32; 3]]) {
|
||||
if m.ai.nav.is_done() {
|
||||
return;
|
||||
}
|
||||
for &((x, y, z), old, new) in writes {
|
||||
let at = [x, y, z];
|
||||
let shape = |state| w.collision_bounds_of(horizon_world::registry::BlockStateId(state), at);
|
||||
if shape(old) != shape(new) && m.should_recompute_path(at) {
|
||||
for &at in changed {
|
||||
if m.should_recompute_path(at) {
|
||||
m.queue_path_recompute();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The writes of `writes` that changed the block's collision shape, in order:
|
||||
/// `sendBlockUpdated` compares the two shapes once per write, not per mob.
|
||||
pub fn collision_changes(w: &MobWorld, writes: &[((i32, i32, i32), u32, u32)]) -> Vec<[i32; 3]> {
|
||||
writes
|
||||
.iter()
|
||||
.filter_map(|&((x, y, z), old, new)| {
|
||||
let at = [x, y, z];
|
||||
let shape =
|
||||
|state| w.collision_bounds_of(horizon_world::registry::BlockStateId(state), at);
|
||||
(shape(old) != shape(new)).then_some(at)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
|
||||
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
|
||||
// Entity.push(x, y, z) sets needsSync.
|
||||
let mut add = |v: Vec3| {
|
||||
@@ -2194,6 +2231,8 @@ pub struct MobWorld<'a> {
|
||||
pub biomes: &'a crate::biome::Biomes,
|
||||
/// The `mob_griefing` game rule.
|
||||
pub mob_griefing: bool,
|
||||
/// The `universal_anger` game rule (`NeutralMob.isAngryAtAllPlayers`).
|
||||
pub universal_anger: bool,
|
||||
/// The `mob_drops` game rule (`shouldDropLoot`).
|
||||
pub mob_drops: bool,
|
||||
/// The `max_entity_cramming` game rule.
|
||||
@@ -2432,6 +2471,14 @@ impl Mob {
|
||||
) -> bool {
|
||||
axolotl::before_damage(self, amount, &source);
|
||||
raider::note_hurt(self);
|
||||
// SulfurCube.hurtServer: the priming and a block's immune knockback
|
||||
// come before LivingEntity's checks, so a primed (invulnerable) cube
|
||||
// is still knocked about.
|
||||
if self.species() == Species::SulfurCube
|
||||
&& !sulfur_cube::accepts_damage(self, &source, amount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// LivingEntity.isInvulnerableTo: only #bypasses_invulnerability gets past it.
|
||||
if !self.alive()
|
||||
|| self.removed
|
||||
@@ -2772,6 +2819,49 @@ impl Mob {
|
||||
pub fn is_invisible(&self) -> bool {
|
||||
self.invisible || self.has_effect("minecraft:invisibility")
|
||||
}
|
||||
/// `LivingEntity.addEffect` of a `MobEffectInstance` with its flags: the effect as
|
||||
/// `add_effect` has it, and `MobEffectInstance.update`'s rules for the flags of one already
|
||||
/// there: it takes the new ambient flag when the new one is not ambient or the effect
|
||||
/// changed, and always the new visibility.
|
||||
pub fn add_effect_with(
|
||||
&mut self,
|
||||
effect: &'static str,
|
||||
ticks: i32,
|
||||
amplifier: i32,
|
||||
ambient: bool,
|
||||
visible: bool,
|
||||
) {
|
||||
let effect = horizon_client_core::potions::namespaced(effect);
|
||||
let before = self
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.find(|e| e.0 == effect)
|
||||
.map(|e| (e.1, e.2));
|
||||
self.add_effect(effect, ticks, amplifier);
|
||||
let after = self
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.find(|e| e.0 == effect)
|
||||
.map(|e| (e.1, e.2));
|
||||
let present_ambient = self.extra.ambient_effects.contains(&effect);
|
||||
// A new effect takes its flags as they are; one already there by update's rules.
|
||||
let take_ambient = match before {
|
||||
None => true,
|
||||
Some(_) => (!ambient && present_ambient) || before != after,
|
||||
};
|
||||
if take_ambient {
|
||||
self.extra.ambient_effects.retain(|h| *h != effect);
|
||||
if ambient {
|
||||
self.extra.ambient_effects.push(effect);
|
||||
}
|
||||
}
|
||||
self.extra.hidden_effects.retain(|h| *h != effect);
|
||||
if !visible {
|
||||
self.extra.hidden_effects.push(effect);
|
||||
}
|
||||
}
|
||||
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
|
||||
let effect = horizon_client_core::potions::namespaced(effect);
|
||||
if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) {
|
||||
@@ -3714,6 +3804,36 @@ mod magic_absorb_tests {
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
/// `MobEffectInstance.update` for the flags of an effect a mob has: a new instance that is
|
||||
/// not ambient takes the ambient flag off an ambient one, one that is ambient does not unless
|
||||
/// the effect itself changes, and visibility is always the new instance's.
|
||||
#[test]
|
||||
fn an_effect_added_again_updates_its_ambient_and_visible_flags_as_vanilla() {
|
||||
let mut m = witch();
|
||||
let speed = "minecraft:speed";
|
||||
// A new effect takes its flags as they are.
|
||||
m.add_effect_with("speed", 600, 0, true, false);
|
||||
assert!(
|
||||
m.extra.ambient_effects.contains(&speed) && m.extra.hidden_effects.contains(&speed)
|
||||
);
|
||||
// The same effect, no stronger and no longer, ambient again and visible: ambient stays
|
||||
// (nothing changed), the visibility follows.
|
||||
m.add_effect_with("speed", 100, 0, true, true);
|
||||
assert_eq!(m.extra.effects, vec![(speed, 600, 0)]);
|
||||
assert!(
|
||||
m.extra.ambient_effects.contains(&speed) && !m.extra.hidden_effects.contains(&speed)
|
||||
);
|
||||
// Not ambient: it takes the flag off, though nothing else changed.
|
||||
m.add_effect_with("speed", 100, 0, false, true);
|
||||
assert!(!m.extra.ambient_effects.contains(&speed));
|
||||
// Ambient over a not ambient one that does not change: not taken; one that changes: taken.
|
||||
m.add_effect_with("speed", 100, 0, true, true);
|
||||
assert!(!m.extra.ambient_effects.contains(&speed));
|
||||
m.add_effect_with("speed", 900, 0, true, true);
|
||||
assert!(m.extra.ambient_effects.contains(&speed));
|
||||
assert_eq!(m.extra.effects, vec![(speed, 900, 0)]);
|
||||
}
|
||||
|
||||
/// `Witch.getDamageAfterMagicAbsorb`: `#witch_resistant_to` (`magic`,
|
||||
/// `indirect_magic`, `sonic_boom`, `thorns`) takes 15% of the hit, which
|
||||
/// leaves a 10 health witch on 7 of a 20 hit it would not survive whole.
|
||||
|
||||
@@ -2099,7 +2099,6 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, _amount: f32) -> bool {
|
||||
Species::Wither => super::wither::accepts(m, source.kind, source.attacker, false),
|
||||
Species::Warden => super::warden::accepts_damage(m, source),
|
||||
Species::Shulker => super::shulker::accepts(m, source),
|
||||
Species::SulfurCube => super::sulfur_cube::accepts_damage(m, source, _amount),
|
||||
// Bat.hurtServer: a hit wakes it.
|
||||
Species::Bat => {
|
||||
super::bat::hurt(m);
|
||||
@@ -2446,6 +2445,9 @@ fn tick_effects(m: &mut Mob) {
|
||||
m.extra
|
||||
.hidden_effects
|
||||
.retain(|h| effects.iter().any(|e| e.0 == *h));
|
||||
m.extra
|
||||
.ambient_effects
|
||||
.retain(|h| effects.iter().any(|e| e.0 == *h));
|
||||
m.extra.effects = effects;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ use super::{
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use crate::sculk::{self, Context, Info};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
// Allay state: `main_hand` the held item, `ints[1]` how many it carries,
|
||||
// `tame` a liked player, `point` the wanted item (set by the game),
|
||||
@@ -74,6 +74,21 @@ pub fn considers_item_equal(held: &ItemStack, candidate: &ItemStack) -> bool {
|
||||
held.item == candidate.item && held.potion() == candidate.potion()
|
||||
}
|
||||
|
||||
/// `Allay.setDancing` marks an allay dancing only under `isEffectiveAi()`, so
|
||||
/// `NoAI` never dances; panicking stops it, and 10 blocks is the jukebox reach
|
||||
/// (`distance_sq` to the nearest playing jukebox's centre, infinite for none).
|
||||
pub fn dances(no_ai: bool, panicking: bool, distance_sq: f64) -> bool {
|
||||
!no_ai && !panicking && distance_sq <= 100.0
|
||||
}
|
||||
|
||||
/// `Mob.getPickupReach` (1, 1, 1) around the box: `Mob.aiStep`'s looting loop
|
||||
/// takes an equal drop from here with or without AI, so it is the only pickup a
|
||||
/// `NoAI` allay has (`canPickUpLoot` and `wantsToPickUp` never ask for AI;
|
||||
/// walking to a drop is the sensor's and throwing it is the brain's).
|
||||
pub fn pickup_reach(bounds: Aabb) -> Aabb {
|
||||
bounds.inflate(Vec3::new(1.0, 1.0, 1.0))
|
||||
}
|
||||
|
||||
/// An allay's vibration listener (`Allay.vibrationData`) and the brain
|
||||
/// memory `LIKED_NOTEBLOCK_POSITION`; `LIKED_NOTEBLOCK_COOLDOWN_TICKS` is
|
||||
/// `ints[2]`.
|
||||
@@ -458,4 +473,32 @@ mod allay_parity_tests {
|
||||
&potion("minecraft:swiftness")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_ai_allay_does_not_dance_to_a_playing_jukebox() {
|
||||
// Beside a playing jukebox and at the edge of its 10-block reach.
|
||||
for distance_sq in [0.0, 1.5 * 1.5, 99.0, 100.0] {
|
||||
assert!(!dances(true, false, distance_sq), "NoAI at {distance_sq}");
|
||||
assert!(dances(false, false, distance_sq), "AI at {distance_sq}");
|
||||
}
|
||||
// Out of reach, or panicking: no allay dances.
|
||||
assert!(!dances(false, false, 100.5));
|
||||
assert!(!dances(false, true, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_no_ai_allay_takes_only_a_drop_already_in_pickup_reach() {
|
||||
// An allay at 0.5, 64, 0.5: 0.6 wide and tall.
|
||||
let bounds = Aabb::new(Vec3::new(0.2, 64.0, 0.2), Vec3::new(0.8, 64.6, 0.8));
|
||||
let reach = pickup_reach(bounds);
|
||||
// `Mob.aiStep`'s looting loop takes a drop at its feet, and one just
|
||||
// past the box, with no AI.
|
||||
assert!(reach.contains(Vec3::new(0.5, 64.0, 0.5)));
|
||||
assert!(reach.contains(Vec3::new(0.5, 64.0, 1.7)));
|
||||
// Past the one-block grow it would have to walk (`GoToWantedItem` is
|
||||
// the brain's), and 32 blocks is the sensor's reach, not this one.
|
||||
assert!(!reach.contains(Vec3::new(0.5, 64.0, 1.9)));
|
||||
assert!(!reach.contains(Vec3::new(0.5, 64.0, 20.0)));
|
||||
assert!(!reach.contains(Vec3::new(0.5, 66.0, 0.5)));
|
||||
}
|
||||
}
|
||||
@@ -609,7 +609,7 @@ mod tests {
|
||||
"{{id:\"minecraft:camel\",Pos:[0.0d,0.0d,0.0d],LastPoseTick:{tick}L}}"
|
||||
))
|
||||
.unwrap();
|
||||
let mut m = crate::entity_nbt::mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let mut m = crate::entity_nbt::mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
finish_load(&mut m, now);
|
||||
m
|
||||
};
|
||||
@@ -628,7 +628,7 @@ mod tests {
|
||||
let (tag, _) =
|
||||
horizon_world::nbt::parse_snbt("{id:\"minecraft:camel\",Pos:[0.0d,0.0d,0.0d]}")
|
||||
.unwrap();
|
||||
let mut m = crate::entity_nbt::mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let mut m = crate::entity_nbt::mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
finish_load(&mut m, 5000);
|
||||
assert!(!refuse_to_move(&m));
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ pub fn single(from: &Mob, to: &mut Mob, params: Params) {
|
||||
// convertCommon.
|
||||
to.extra.effects = from.extra.effects.clone();
|
||||
to.extra.hidden_effects = from.extra.hidden_effects.clone();
|
||||
to.extra.ambient_effects = from.extra.ambient_effects.clone();
|
||||
if from.is_baby() {
|
||||
// setBaby(true); an ageable pair keeps the exact age.
|
||||
to.extra.age = from.extra.age;
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
//! `Mob.equipItemIfPossible` and the comparisons behind it: `compareArmor`,
|
||||
//! `compareWeapons`, `canReplaceEqualItem` and the per-species gates that
|
||||
//! decide whether a mob takes an item off the ground at all (`wantsToPickUp`,
|
||||
//! `canHoldItem`). `game/mob_looting.rs` reads the item data and calls these.
|
||||
use super::Species;
|
||||
|
||||
/// An `EquipmentSlot`, in the order `finalize::item` and `drops::SLOTS` keep
|
||||
/// them.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Slot {
|
||||
MainHand,
|
||||
OffHand,
|
||||
Feet,
|
||||
Legs,
|
||||
Chest,
|
||||
Head,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// `EquipmentSlot.isArmor`: the four armour slots, not the hands.
|
||||
pub fn is_armor(self) -> bool {
|
||||
matches!(self, Slot::Feet | Slot::Legs | Slot::Chest | Slot::Head)
|
||||
}
|
||||
/// The `drops::SLOTS` index (`set_item`, `item`, `set_drop_chance`).
|
||||
pub fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// `LivingEntity.getEquipmentSlotForItem`: the item's own `equippable` slot,
|
||||
/// else the main hand. `None` for `body` and `saddle`: those name the wearer
|
||||
/// they belong to (`Equippable.canBeEquippedBy`), which Horizon does not
|
||||
/// model, so no mob of this pass puts them on.
|
||||
pub fn slot_for(equip_slot: Option<&str>) -> Option<Slot> {
|
||||
match equip_slot {
|
||||
None | Some("mainhand") => Some(Slot::MainHand),
|
||||
Some("offhand") => Some(Slot::OffHand),
|
||||
Some("head") => Some(Slot::Head),
|
||||
Some("chest") => Some(Slot::Chest),
|
||||
Some("legs") => Some(Slot::Legs),
|
||||
Some("feet") => Some(Slot::Feet),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `EquipmentSlotGroup.test`: whether a modifier the item records for `group`
|
||||
/// counts in `slot`.
|
||||
fn group_matches(group: &str, slot: Slot) -> bool {
|
||||
match group {
|
||||
"any" => true,
|
||||
"hand" => matches!(slot, Slot::MainHand | Slot::OffHand),
|
||||
"armor" => slot.is_armor(),
|
||||
"mainhand" => slot == Slot::MainHand,
|
||||
"offhand" => slot == Slot::OffHand,
|
||||
"head" => slot == Slot::Head,
|
||||
"chest" => slot == Slot::Chest,
|
||||
"legs" => slot == Slot::Legs,
|
||||
"feet" => slot == Slot::Feet,
|
||||
// `body` and `saddle` are slots this pass never equips into.
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ItemAttributeModifiers.compute(attribute, base, slot)`: `base` (the mob's
|
||||
/// own attribute value) plus the item's modifiers for `attribute` that apply
|
||||
/// in `slot`, in the order the item lists them.
|
||||
pub fn attribute_with(
|
||||
modifiers: &[(String, String, f64, String, String)],
|
||||
attribute: &str,
|
||||
base: f64,
|
||||
slot: Slot,
|
||||
) -> f64 {
|
||||
let mut value = base;
|
||||
for (kind, _, amount, operation, group) in modifiers {
|
||||
if kind != attribute || !group_matches(group, slot) {
|
||||
continue;
|
||||
}
|
||||
value += match operation.as_str() {
|
||||
"add_multiplied_base" => amount * base,
|
||||
"add_multiplied_total" => amount * value,
|
||||
_ => *amount,
|
||||
};
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
/// What the comparisons read about one stack: `getApproximateAttributeWith`
|
||||
/// over its `attribute_modifiers`, and the three facts `canReplaceEqualItem`
|
||||
/// compares.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Facts {
|
||||
pub armor: f64,
|
||||
pub armor_toughness: f64,
|
||||
pub attack_damage: f64,
|
||||
/// How many enchantments the stack carries (`ENCHANTMENTS`).
|
||||
pub enchantments: usize,
|
||||
pub damage: i32,
|
||||
pub custom_name: bool,
|
||||
/// `EnchantmentEffectComponents.PREVENT_ARMOR_CHANGE` (Curse of Binding).
|
||||
pub prevents_armor_change: bool,
|
||||
}
|
||||
|
||||
/// `Mob.canReplaceCurrentItem`: whether the stack the mob could take beats
|
||||
/// what it has in `slot`. `preferred` is `getPreferredWeaponType`'s tag
|
||||
/// answer for the two stacks, `(new, current)`; `None` when the mob has none.
|
||||
pub fn can_replace_current_item(
|
||||
new: &Facts,
|
||||
current: Option<&Facts>,
|
||||
slot: Slot,
|
||||
preferred: Option<(bool, bool)>,
|
||||
) -> bool {
|
||||
let Some(current) = current else {
|
||||
return true;
|
||||
};
|
||||
if slot.is_armor() {
|
||||
compare_armor(new, current)
|
||||
} else if slot == Slot::MainHand {
|
||||
compare_weapons(new, current, preferred)
|
||||
} else {
|
||||
// The off hand is never replaced.
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mob.compareArmor`: more armour wins, then more toughness, then
|
||||
/// `canReplaceEqualItem`. A piece with `PREVENT_ARMOR_CHANGE` is never taken
|
||||
/// off.
|
||||
pub fn compare_armor(new: &Facts, current: &Facts) -> bool {
|
||||
if current.prevents_armor_change {
|
||||
return false;
|
||||
}
|
||||
if new.armor != current.armor {
|
||||
return new.armor > current.armor;
|
||||
}
|
||||
if new.armor_toughness != current.armor_toughness {
|
||||
return new.armor_toughness > current.armor_toughness;
|
||||
}
|
||||
can_replace_equal_item(new, current)
|
||||
}
|
||||
|
||||
/// `Mob.compareWeapons`: a weapon in the mob's preferred tag beats one
|
||||
/// outside it, then more attack damage wins, then `canReplaceEqualItem`.
|
||||
pub fn compare_weapons(new: &Facts, current: &Facts, preferred: Option<(bool, bool)>) -> bool {
|
||||
if let Some((new_is, current_is)) = preferred {
|
||||
if current_is && !new_is {
|
||||
return false;
|
||||
}
|
||||
if new_is && !current_is {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if new.attack_damage != current.attack_damage {
|
||||
return new.attack_damage > current.attack_damage;
|
||||
}
|
||||
can_replace_equal_item(new, current)
|
||||
}
|
||||
|
||||
/// `Mob.canReplaceEqualItem`: more enchantments wins, then less damage, then a
|
||||
/// custom name.
|
||||
pub fn can_replace_equal_item(new: &Facts, current: &Facts) -> bool {
|
||||
if new.enchantments != current.enchantments {
|
||||
return new.enchantments > current.enchantments;
|
||||
}
|
||||
if new.damage != current.damage {
|
||||
return new.damage < current.damage;
|
||||
}
|
||||
new.custom_name && !current.custom_name
|
||||
}
|
||||
|
||||
/// `Mob.getPreferredWeaponType` of the species that have one; the wither
|
||||
/// skeleton overrides it back to null.
|
||||
pub fn preferred_weapon_tag(species: Species) -> Option<&'static str> {
|
||||
match species {
|
||||
Species::Skeleton | Species::Stray | Species::Bogged | Species::Parched => {
|
||||
Some("minecraft:skeleton_preferred_weapons")
|
||||
}
|
||||
Species::Drowned => Some("minecraft:drowned_preferred_weapons"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_skeleton(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Skeleton
|
||||
| Species::Stray
|
||||
| Species::Bogged
|
||||
| Species::Parched
|
||||
| Species::WitherSkeleton
|
||||
)
|
||||
}
|
||||
fn is_zombie(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Zombie | Species::Husk | Species::Drowned | Species::ZombieVillager
|
||||
)
|
||||
}
|
||||
|
||||
/// The tag facts `wants_to_pick_up` reads about one item.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct ItemTags {
|
||||
/// `#minecraft:spears`.
|
||||
pub spear: bool,
|
||||
/// `#minecraft:eggs`.
|
||||
pub egg: bool,
|
||||
/// `#minecraft:wither_skeleton_disliked_weapons`.
|
||||
pub disliked_weapon: bool,
|
||||
}
|
||||
|
||||
/// `Mob.aiStep`'s looting gate, `Mob.wantsToPickUp` with `canHoldItem`, for
|
||||
/// the species whose pickup ends in the base `Mob.pickUpItem` (the equip of
|
||||
/// `equipItemIfPossible`). A villager, piglin, allay, panda, sulfur cube,
|
||||
/// fox, dolphin or pillager overrides the pickup itself and has its own loop
|
||||
/// elsewhere; an illager's `Raider.pickUpItem` puts the ominous banner on the
|
||||
/// head, which Horizon does not model, so they answer `false` here too.
|
||||
pub fn wants_to_pick_up(
|
||||
species: Species,
|
||||
baby: bool,
|
||||
passenger: bool,
|
||||
item: &str,
|
||||
tags: ItemTags,
|
||||
) -> bool {
|
||||
if !matches!(
|
||||
species,
|
||||
Species::Zombie
|
||||
| Species::Husk
|
||||
| Species::Drowned
|
||||
| Species::ZombieVillager
|
||||
| Species::ZombifiedPiglin
|
||||
) && !is_skeleton(species)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// `AbstractSkeleton.wantsToPickUp` and `Drowned.wantsToPickUp`.
|
||||
if (is_skeleton(species) || species == Species::Drowned) && tags.spear {
|
||||
return false;
|
||||
}
|
||||
// `Zombie.wantsToPickUp` (the other zombies) refuses glow ink;
|
||||
// `ZombifiedPiglin.wantsToPickUp` is a bare `canHoldItem`.
|
||||
if is_zombie(species) && item == "minecraft:glow_ink_sac" {
|
||||
return false;
|
||||
}
|
||||
// `Zombie.canHoldItem`: a baby riding something leaves eggs alone.
|
||||
if (is_zombie(species) || species == Species::ZombifiedPiglin) && baby && passenger && tags.egg
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// `WitherSkeleton.canHoldItem`.
|
||||
if species == Species::WitherSkeleton && tags.disliked_weapon {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn facts(armor: f64, toughness: f64, damage: f64) -> Facts {
|
||||
Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: damage,
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
fn sword(damage: f64) -> Facts {
|
||||
facts(0.0, 0.0, damage)
|
||||
}
|
||||
/// `canReplaceCurrentItem` for an item that goes in the main hand, from a
|
||||
/// mob with no preferred weapon tag.
|
||||
fn replaces_weapon(new: &Facts, current: &Facts) -> bool {
|
||||
can_replace_current_item(new, Some(current), Slot::MainHand, None)
|
||||
}
|
||||
/// The same for a chest piece.
|
||||
fn replaces_armour(new: &Facts, current: &Facts) -> bool {
|
||||
can_replace_current_item(new, Some(current), Slot::Chest, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_better_weapon_replaces_a_worse_one_and_not_the_reverse() {
|
||||
let (iron, wood) = (sword(7.0), sword(5.0));
|
||||
assert!(replaces_weapon(&iron, &wood));
|
||||
assert!(!replaces_weapon(&wood, &iron));
|
||||
// An empty hand takes anything.
|
||||
assert!(can_replace_current_item(&wood, None, Slot::MainHand, None));
|
||||
// A hand already holding it keeps it: equal damage, equal everything.
|
||||
assert!(!replaces_weapon(&iron, &iron));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armour_with_more_armour_or_toughness_wins() {
|
||||
let (diamond, iron) = (facts(8.0, 2.0, 0.0), facts(6.0, 0.0, 0.0));
|
||||
assert!(replaces_armour(&diamond, &iron));
|
||||
assert!(!replaces_armour(&iron, &diamond));
|
||||
// Equal armour: the tougher piece wins.
|
||||
let tough = facts(8.0, 3.0, 0.0);
|
||||
assert!(replaces_armour(&tough, &diamond));
|
||||
assert!(!replaces_armour(&diamond, &tough));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_equal_weapon_with_enchantments_wins() {
|
||||
let plain = sword(7.0);
|
||||
let sharp = Facts {
|
||||
enchantments: 2,
|
||||
..sword(7.0)
|
||||
};
|
||||
assert!(replaces_weapon(&sharp, &plain));
|
||||
assert!(!replaces_weapon(&plain, &sharp));
|
||||
// Same enchantment count: the less damaged one wins, then a named one.
|
||||
let worn = Facts {
|
||||
damage: 40,
|
||||
..sharp
|
||||
};
|
||||
assert!(replaces_weapon(&sharp, &worn));
|
||||
let named = Facts {
|
||||
custom_name: true,
|
||||
..sharp
|
||||
};
|
||||
assert!(replaces_weapon(&named, &sharp));
|
||||
assert!(!replaces_weapon(&sharp, &named));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_piece_that_prevents_armour_change_is_never_replaced() {
|
||||
let plain = facts(2.0, 0.0, 0.0);
|
||||
let cursed = Facts {
|
||||
prevents_armor_change: true,
|
||||
..plain
|
||||
};
|
||||
let better = facts(8.0, 3.0, 0.0);
|
||||
assert!(!replaces_armour(&better, &cursed));
|
||||
assert!(replaces_armour(&better, &plain));
|
||||
// The weapon comparison does not look at it.
|
||||
let cursed_sword = Facts {
|
||||
prevents_armor_change: true,
|
||||
..sword(1.0)
|
||||
};
|
||||
assert!(replaces_weapon(&sword(7.0), &cursed_sword));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preferred_weapon_beats_a_stronger_one_outside_the_tag() {
|
||||
let (axe, bow) = (sword(9.0), sword(1.0));
|
||||
// The skeleton's bow counts as a preferred weapon, both ways round.
|
||||
let main = Slot::MainHand;
|
||||
assert!(!can_replace_current_item(
|
||||
&axe,
|
||||
Some(&bow),
|
||||
main,
|
||||
Some((false, true))
|
||||
));
|
||||
assert!(can_replace_current_item(
|
||||
&bow,
|
||||
Some(&axe),
|
||||
main,
|
||||
Some((true, false))
|
||||
));
|
||||
// Both in the tag: the damage decides.
|
||||
assert!(can_replace_current_item(
|
||||
&axe,
|
||||
Some(&bow),
|
||||
main,
|
||||
Some((true, true))
|
||||
));
|
||||
assert!(!can_replace_current_item(
|
||||
&bow,
|
||||
Some(&axe),
|
||||
main,
|
||||
Some((true, true))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_modifiers_apply_by_slot_group_and_operation() {
|
||||
let entry = |attribute: &str, amount: f64, operation: &str, group: &str| {
|
||||
(
|
||||
attribute.to_owned(),
|
||||
"minecraft:test".to_owned(),
|
||||
amount,
|
||||
operation.to_owned(),
|
||||
group.to_owned(),
|
||||
)
|
||||
};
|
||||
let modifiers = [
|
||||
entry("minecraft:armor", 8.0, "add_value", "chest"),
|
||||
entry("minecraft:attack_damage", 7.0, "add_value", "mainhand"),
|
||||
entry("minecraft:attack_damage", 1.0, "add_multiplied_base", "any"),
|
||||
];
|
||||
// A chestplate's armour does not count in the feet slot.
|
||||
assert_eq!(
|
||||
attribute_with(&modifiers, "minecraft:armor", 0.0, Slot::Chest),
|
||||
8.0
|
||||
);
|
||||
assert_eq!(
|
||||
attribute_with(&modifiers, "minecraft:armor", 0.0, Slot::Feet),
|
||||
0.0
|
||||
);
|
||||
// The mob's own base damage is the start, and `any` applies.
|
||||
let damage = |slot| attribute_with(&modifiers, "minecraft:attack_damage", 1.0, slot);
|
||||
assert_eq!(damage(Slot::MainHand), 1.0 + 7.0 + 1.0);
|
||||
assert_eq!(damage(Slot::OffHand), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_go_in_their_own_slot() {
|
||||
assert_eq!(slot_for(None), Some(Slot::MainHand));
|
||||
assert_eq!(slot_for(Some("head")), Some(Slot::Head));
|
||||
assert_eq!(slot_for(Some("feet")), Some(Slot::Feet));
|
||||
// A body or saddle item names its wearer, which no mob of this pass is.
|
||||
assert_eq!(slot_for(Some("body")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pickup_gate_follows_the_species_overrides() {
|
||||
let tags = |spear, egg, disliked_weapon| ItemTags {
|
||||
spear,
|
||||
egg,
|
||||
disliked_weapon,
|
||||
};
|
||||
let none = tags(false, false, false);
|
||||
let gate = |species, baby, item, t| wants_to_pick_up(species, baby, false, item, t);
|
||||
// A zombie takes a sword; a skeleton refuses a spear but takes a bow.
|
||||
assert!(gate(
|
||||
Species::Zombie,
|
||||
false,
|
||||
"minecraft:diamond_sword",
|
||||
none
|
||||
));
|
||||
assert!(!gate(
|
||||
Species::Skeleton,
|
||||
false,
|
||||
"minecraft:iron_spear",
|
||||
tags(true, false, false)
|
||||
));
|
||||
assert!(gate(Species::Skeleton, false, "minecraft:bow", none));
|
||||
// `Zombie.wantsToPickUp` refuses glow ink; a zombified piglin does not.
|
||||
assert!(!gate(Species::Husk, false, "minecraft:glow_ink_sac", none));
|
||||
assert!(gate(
|
||||
Species::ZombifiedPiglin,
|
||||
false,
|
||||
"minecraft:glow_ink_sac",
|
||||
none
|
||||
));
|
||||
// A baby zombie riding a chicken leaves eggs alone; on foot it takes one.
|
||||
let eggs = tags(false, true, false);
|
||||
assert!(!wants_to_pick_up(
|
||||
Species::Zombie,
|
||||
true,
|
||||
true,
|
||||
"minecraft:egg",
|
||||
eggs
|
||||
));
|
||||
assert!(gate(Species::Zombie, true, "minecraft:egg", eggs));
|
||||
assert!(gate(Species::Husk, false, "minecraft:egg", eggs));
|
||||
// The wither skeleton and its disliked weapons.
|
||||
assert!(!gate(
|
||||
Species::WitherSkeleton,
|
||||
false,
|
||||
"minecraft:iron_sword",
|
||||
tags(false, false, true)
|
||||
));
|
||||
// The species whose pickup is elsewhere never answer yes.
|
||||
for s in [
|
||||
Species::Villager,
|
||||
Species::Piglin,
|
||||
Species::Panda,
|
||||
Species::Allay,
|
||||
Species::Fox,
|
||||
Species::Vindicator,
|
||||
] {
|
||||
assert!(!gate(s, false, "minecraft:diamond_sword", none));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1026,6 +1026,35 @@ pub fn stored_drop_chance(m: &Mob, slot: usize) -> f32 {
|
||||
.map_or(super::drops::DEFAULT_DROP_CHANCE, |c| c[slot])
|
||||
}
|
||||
|
||||
/// `SpecialDates.isHalloween`: October 31.
|
||||
pub fn halloween_date(month: u32, day: u32) -> bool {
|
||||
(month, day) == (10, 31)
|
||||
}
|
||||
|
||||
/// `SpecialDates.isHalloween` from the local calendar.
|
||||
pub fn is_halloween() -> bool {
|
||||
use chrono::Datelike;
|
||||
let today = chrono::Local::now();
|
||||
halloween_date(today.month(), today.day())
|
||||
}
|
||||
|
||||
/// `Zombie` / `AbstractSkeleton` halloween pumpkin: 25% of empty heads, 10%
|
||||
/// of those a jack o'lantern, drop chance 0.
|
||||
pub fn halloween_head(m: &mut Mob, random: &mut impl RandomSource, halloween: bool) {
|
||||
if !(zombie(m.species()) || skeleton(m.species())) {
|
||||
return;
|
||||
}
|
||||
if m.extra.head.is_none() && halloween && random.next_float() < 0.25 {
|
||||
let head = if random.next_float() < 0.1 {
|
||||
"minecraft:jack_o_lantern"
|
||||
} else {
|
||||
"minecraft:carved_pumpkin"
|
||||
};
|
||||
set_item(m, HEAD, head);
|
||||
set_drop_chance(m, HEAD, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
|
||||
fn float(&mut self) -> f32 {
|
||||
self.random.next_float()
|
||||
@@ -1236,15 +1265,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
|
||||
}
|
||||
}
|
||||
fn halloween(&mut self, m: &mut Mob) {
|
||||
if m.extra.head.is_none() && self.level.halloween() && self.float() < 0.25 {
|
||||
let head = if self.float() < 0.1 {
|
||||
"minecraft:jack_o_lantern"
|
||||
} else {
|
||||
"minecraft:carved_pumpkin"
|
||||
};
|
||||
set_item(m, HEAD, head);
|
||||
set_drop_chance(m, HEAD, 0.0);
|
||||
}
|
||||
halloween_head(m, self.random, self.level.halloween());
|
||||
}
|
||||
|
||||
// ---- jockeys ------------------------------------------------------
|
||||
@@ -2299,4 +2320,11 @@ mod tests {
|
||||
assert_eq!(villager_type("minecraft:grove"), 4);
|
||||
assert_eq!(villager_type("minecraft:plains"), 0);
|
||||
}
|
||||
#[test]
|
||||
fn october_31_is_halloween() {
|
||||
assert!(halloween_date(10, 31));
|
||||
assert!(!halloween_date(10, 30));
|
||||
assert!(!halloween_date(11, 1));
|
||||
assert!(!halloween_date(1, 1));
|
||||
}
|
||||
}
|
||||
@@ -680,6 +680,18 @@ impl Mob {
|
||||
.as_ref()
|
||||
.is_some_and(|p| super::tame::owned_by(self.extra.owner, p.uuid)))
|
||||
}
|
||||
/// `canAttack` of any target: the player rules above, and
|
||||
/// `IronGolem.canAttack`'s refusal of creepers. The callers check that
|
||||
/// the target is alive and can be seen as an enemy.
|
||||
pub fn spares_target(&self, w: &MobWorld, who: Who) -> bool {
|
||||
match who {
|
||||
Who::Player => self.spares_players(w),
|
||||
Who::Mob(id) => {
|
||||
self.species() == Species::IronGolem
|
||||
&& w.seen(id).is_some_and(|s| s.species == Species::Creeper)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// TargetingConditions.forCombat().test for the player: alive, not
|
||||
/// creative, within range scaled by visibility, optionally visible.
|
||||
pub fn can_target_player(&mut self, w: &MobWorld, range: f64, check_sight: bool) -> bool {
|
||||
@@ -1593,7 +1605,10 @@ pub fn target_continue(st: &mut TargetState, must_see: bool, m: &mut Mob, cx: &m
|
||||
let Some(t) = cx.w.target_info(who) else {
|
||||
return false;
|
||||
};
|
||||
if !t.alive || !t.can_be_seen_as_enemy || t.is_player && (t.creative || m.spares_players(cx.w))
|
||||
if !t.alive
|
||||
|| !t.can_be_seen_as_enemy
|
||||
|| (t.is_player && t.creative)
|
||||
|| m.spares_target(cx.w, who)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1787,7 +1802,9 @@ impl Goal for HurtByTarget {
|
||||
};
|
||||
t.alive
|
||||
&& t.can_be_seen_as_enemy
|
||||
&& !(t.is_player && (t.creative || m.spares_players(cx.w)))
|
||||
&& !(t.is_player && t.creative)
|
||||
// HurtByTargetGoal.canUse ends with canAttack(lastHurtByMob).
|
||||
&& !m.spares_target(cx.w, by)
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
if self.bee && !super::bee::angry(m, cx.w) {
|
||||
|
||||
@@ -20,8 +20,8 @@ use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
fn golem_prey(s: &super::Seen) -> bool {
|
||||
s.hostile && s.species != Species::Creeper
|
||||
}
|
||||
/// `IronGolem.registerGoals`, without `ResetUniversalAngerTargetGoal`
|
||||
/// (nothing reads the `universal_anger` rule yet).
|
||||
/// `IronGolem.registerGoals`, without `ResetUniversalAngerTargetGoal` (that
|
||||
/// goal is not ported).
|
||||
pub fn register_iron(m: &mut Mob) {
|
||||
let g = &mut m.ai.goals;
|
||||
g.add(1, MeleeAttack::new(1.0, true));
|
||||
@@ -535,3 +535,72 @@ pub fn snow_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
}
|
||||
let _ = z;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::{test_player, with_test_world};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mob(id: u32, species: Species, at: Vec3) -> Mob {
|
||||
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
kind.species = species;
|
||||
Mob::new(id, Arc::new(kind), at, 0.0)
|
||||
}
|
||||
|
||||
/// `IronGolem.canAttack` refuses creepers, and `HurtByTargetGoal.canUse`
|
||||
/// asks it: the creeper that hurt the golem is never fought back, while
|
||||
/// a zombie that hurts it is.
|
||||
#[test]
|
||||
fn a_creeper_that_hurts_an_iron_golem_is_never_fought_back() {
|
||||
let creeper = mob(2, Species::Creeper, Vec3::new(1.5, 64.0, 0.5));
|
||||
let zombie = mob(3, Species::Zombie, Vec3::new(1.5, 64.0, 0.5));
|
||||
with_test_world(&[creeper.seen(None), zombie.seen(None)], |w, _| {
|
||||
let mut golem = mob(1, Species::IronGolem, Vec3::new(0.5, 64.0, 0.5));
|
||||
let mut goal = HurtByTarget::new();
|
||||
let mut events = Vec::new();
|
||||
let mut lr = horizon_core::random::LegacyRandom::new(0);
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
|
||||
golem.ai.last_hurt_by = Some(Who::Mob(2));
|
||||
golem.ai.last_hurt_by_time = 1;
|
||||
assert!(!goal.can_use(&mut golem, &mut cx));
|
||||
assert_eq!(golem.ai.target, None, "a creeper is refused");
|
||||
|
||||
golem.ai.last_hurt_by = Some(Who::Mob(3));
|
||||
golem.ai.last_hurt_by_time = 2;
|
||||
assert!(goal.can_use(&mut golem, &mut cx));
|
||||
goal.start(&mut golem, &mut cx);
|
||||
assert_eq!(golem.ai.target, Some(Who::Mob(3)));
|
||||
});
|
||||
}
|
||||
|
||||
/// A village golem (not built by a player) still goes after the player
|
||||
/// who angered it.
|
||||
#[test]
|
||||
fn a_village_golem_still_targets_the_player_who_angered_it() {
|
||||
with_test_world(&[], |w, _| {
|
||||
let mut w = w.clone();
|
||||
w.player = Some(test_player(Vec3::new(3.5, 64.0, 0.5)));
|
||||
let mut golem = mob(1, Species::IronGolem, Vec3::new(0.5, 64.0, 0.5));
|
||||
golem.extra.anger = 100;
|
||||
let mut events = Vec::new();
|
||||
let mut lr = horizon_core::random::LegacyRandom::new(0);
|
||||
let mut cx = Cx {
|
||||
w: &w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
let mut goal = NearestTarget::player(true)
|
||||
.interval(0)
|
||||
.when(|m, _| m.extra.anger > 0);
|
||||
assert!(goal.can_use(&mut golem, &mut cx));
|
||||
goal.start(&mut golem, &mut cx);
|
||||
assert_eq!(golem.ai.target, Some(Who::Player));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -344,6 +344,50 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
radius,
|
||||
m.species(),
|
||||
));
|
||||
// Creeper.spawnLingeringCloud: the effects it has make a cloud where it stood, after the
|
||||
// blast. A hidden effect (`hideParticles`) takes no part in the cloud's colour.
|
||||
if !m.extra.effects.is_empty() {
|
||||
// The ids a potion's effects carry: vanilla ones without the namespace.
|
||||
let effects: Vec<_> = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.map(|(id, ticks, amp)| (id.strip_prefix("minecraft:").unwrap_or(id), *ticks, *amp))
|
||||
.collect();
|
||||
let color = horizon_client_core::potions::effects_color(
|
||||
effects
|
||||
.iter()
|
||||
.filter(|(id, ..)| !m.extra.hidden_effects.contains(id))
|
||||
.map(|(id, _, amp)| (*id, *amp)),
|
||||
);
|
||||
let hidden = effects
|
||||
.iter()
|
||||
.filter(|(id, ..)| {
|
||||
m.extra
|
||||
.hidden_effects
|
||||
.iter()
|
||||
.any(|h| h.strip_prefix("minecraft:").unwrap_or(h) == *id)
|
||||
})
|
||||
.map(|(id, ..)| *id)
|
||||
.collect();
|
||||
let ambient = effects
|
||||
.iter()
|
||||
.filter(|(id, ..)| {
|
||||
m.extra
|
||||
.ambient_effects
|
||||
.iter()
|
||||
.any(|h| h.strip_prefix("minecraft:").unwrap_or(h) == *id)
|
||||
})
|
||||
.map(|(id, ..)| *id)
|
||||
.collect();
|
||||
events.push(MobEvent::LingeringCloud {
|
||||
at: [m.position.x, m.position.y, m.position.z],
|
||||
effects,
|
||||
hidden,
|
||||
ambient,
|
||||
color,
|
||||
});
|
||||
}
|
||||
m.removed = true;
|
||||
}
|
||||
}
|
||||
@@ -358,7 +358,7 @@ mod tests {
|
||||
"{{id:\"minecraft:rabbit\",Pos:[0.0d,0.0d,0.0d],Age:{age}}}"
|
||||
))
|
||||
.unwrap();
|
||||
let m = crate::entity_nbt::mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let m = crate::entity_nbt::mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(m.extra.age, age);
|
||||
assert_eq!(m.is_baby(), baby);
|
||||
assert_eq!(
|
||||
@@ -399,10 +399,10 @@ mod tests {
|
||||
// Animal.addAdditionalSaveData / readAdditionalSaveData: "InLove" (#1396).
|
||||
let mut m = rabbit();
|
||||
m.extra.in_love = 570;
|
||||
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None);
|
||||
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None, None);
|
||||
assert_eq!(tag.get("InLove"), Some(&horizon_world::nbt::Tag::Int(570)));
|
||||
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
|
||||
let back = crate::entity_nbt::mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let back = crate::entity_nbt::mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.in_love, 570);
|
||||
}
|
||||
|
||||
|
||||
@@ -2123,10 +2123,50 @@ pub fn held_item_transform(
|
||||
Some(t)
|
||||
}
|
||||
|
||||
/// `ArmedEntityRenderState.extractArmedEntityRenderState`: each arm's item
|
||||
/// model reads the stack held by that arm. Only the main hand currently
|
||||
/// retains a full stack; the off hand must not inherit its item or components.
|
||||
pub fn held_item_stack(extra: &super::Extra, off_hand: bool) -> Option<crate::items::ItemStack> {
|
||||
let item = if off_hand {
|
||||
extra.off_hand.as_ref()?
|
||||
} else {
|
||||
if let Some(stack) = extra.main_hand_stack() {
|
||||
return Some(stack.clone());
|
||||
}
|
||||
extra.main_hand.as_ref()?
|
||||
};
|
||||
Some(crate::items::ItemStack::new(item, 1))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn offhand_does_not_inherit_mainhand_components() {
|
||||
use crate::items::ItemStack;
|
||||
let mut extra = super::super::Extra::default();
|
||||
let mut main = ItemStack::new("minecraft:crossbow", 1);
|
||||
main.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Main hand".into()));
|
||||
extra.set_main_hand_stack(Some(main));
|
||||
extra.off_hand = Some("minecraft:crossbow".into());
|
||||
assert_eq!(
|
||||
held_item_stack(&extra, false)
|
||||
.unwrap()
|
||||
.custom_name()
|
||||
.as_deref(),
|
||||
Some("Main hand")
|
||||
);
|
||||
assert!(
|
||||
held_item_stack(&extra, true)
|
||||
.unwrap()
|
||||
.custom_name()
|
||||
.is_none()
|
||||
);
|
||||
extra.off_hand = None;
|
||||
assert!(held_item_stack(&extra, true).is_none());
|
||||
}
|
||||
|
||||
/// The elytra's flight yaw (`AvatarRenderer.extractFlightData`) lerps
|
||||
/// the velocity of the previous tick towards this tick's, so the model
|
||||
/// does not step once per tick while the view turns (#4104).
|
||||
|
||||
@@ -450,4 +450,46 @@ mod tests {
|
||||
"minecraft:random_spawn_bonus"
|
||||
));
|
||||
}
|
||||
/// `Zombie` / `AbstractSkeleton` halloween garb: 25% of empty heads, never
|
||||
/// on other mobs, and not when it is not Halloween.
|
||||
#[test]
|
||||
fn halloween_pumpkins_only_undead_on_halloween() {
|
||||
let Some(types) = types() else { return };
|
||||
let mut pumpkins = 0;
|
||||
let mut lanterns = 0;
|
||||
for id in 1..=400 {
|
||||
let mut m = spawn(&types, "minecraft:skeleton", id);
|
||||
m.extra.head = None;
|
||||
let mut random = m.random.clone();
|
||||
crate::mobs::finalize::halloween_head(&mut m, &mut random, true);
|
||||
match m.extra.head.as_deref() {
|
||||
Some("minecraft:jack_o_lantern") => {
|
||||
pumpkins += 1;
|
||||
lanterns += 1;
|
||||
assert_eq!(crate::mobs::finalize::stored_drop_chance(&m, 5), 0.0);
|
||||
}
|
||||
Some("minecraft:carved_pumpkin") => {
|
||||
pumpkins += 1;
|
||||
assert_eq!(crate::mobs::finalize::stored_drop_chance(&m, 5), 0.0);
|
||||
}
|
||||
Some(other) => panic!("unexpected head {other}"),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
(70..=130).contains(&pumpkins),
|
||||
"{pumpkins} of 400 skeletons"
|
||||
);
|
||||
assert!(lanterns < pumpkins / 2, "{lanterns} lanterns of {pumpkins}");
|
||||
let mut m = spawn(&types, "minecraft:skeleton", 1);
|
||||
m.extra.head = None;
|
||||
let mut random = m.random.clone();
|
||||
crate::mobs::finalize::halloween_head(&mut m, &mut random, false);
|
||||
assert!(m.extra.head.is_none());
|
||||
let mut cow = spawn(&types, "minecraft:cow", 1);
|
||||
cow.extra.head = None;
|
||||
let mut random = cow.random.clone();
|
||||
crate::mobs::finalize::halloween_head(&mut cow, &mut random, true);
|
||||
assert!(cow.extra.head.is_none());
|
||||
}
|
||||
}
|
||||
@@ -360,8 +360,9 @@ pub fn prime(m: &mut Mob, imminent: bool) -> bool {
|
||||
m.extra.scratch[1] = 1;
|
||||
true
|
||||
}
|
||||
/// `hurtServer`: fire or explosions light an explosive cube; with a block
|
||||
/// it shrugs off blows, falls and explosions and is only knocked about.
|
||||
/// `hurtServer`: fire or `#is_explosion` (a firework rocket's burst too)
|
||||
/// light an explosive cube; with a block it shrugs off blows, falls and
|
||||
/// explosions and is only knocked about.
|
||||
pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
|
||||
if !has_body(m) {
|
||||
return true;
|
||||
@@ -369,7 +370,7 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
|
||||
if can_explode(m) && !primed(m) {
|
||||
if source.is_fire() {
|
||||
prime(m, false);
|
||||
} else if source.kind == DamageKind::Explosion {
|
||||
} else if matches!(source.kind, DamageKind::Explosion | DamageKind::Fireworks) {
|
||||
prime(m, true);
|
||||
}
|
||||
}
|
||||
@@ -382,6 +383,7 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
|
||||
| DamageKind::Arrow
|
||||
| DamageKind::Thrown
|
||||
| DamageKind::Explosion
|
||||
| DamageKind::Fireworks
|
||||
| DamageKind::Fall
|
||||
| DamageKind::Sting
|
||||
| DamageKind::Cactus
|
||||
@@ -458,6 +460,17 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if fuse(m) == 0
|
||||
&& let Some((_, power, _)) = archetype(m).and_then(|a| a.explosion)
|
||||
{
|
||||
// `level.explode(this, ...)`: the cube is the blast's source and its
|
||||
// causing entity, so its entity query leaves it out.
|
||||
let name = match &m.custom_name {
|
||||
Some(name) => crate::text::Text::literal(name.clone()),
|
||||
None => crate::text::Text::translate("entity.minecraft.sulfur_cube", &[]),
|
||||
};
|
||||
events.push(MobEvent::ExplosionSource {
|
||||
mob: m.id,
|
||||
charged: false,
|
||||
name,
|
||||
});
|
||||
events.push(MobEvent::Explode(
|
||||
[m.position.x, m.position.y + 0.0625, m.position.z],
|
||||
power,
|
||||
@@ -726,6 +739,25 @@ mod fuse_anim_tests {
|
||||
Mob::new(1, Arc::new(kind), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_primed_cube_is_still_knocked_about() {
|
||||
// SulfurCube.hurtServer runs the immune knockback before
|
||||
// LivingEntity's invulnerability check, and priming makes it invulnerable.
|
||||
load_archetype_items(&HashMap::from([(
|
||||
"minecraft:sulfur_cube_archetype/explosive".to_owned(),
|
||||
HashSet::from(["minecraft:tnt".to_owned()]),
|
||||
)]));
|
||||
let mut m = cube();
|
||||
m.extra.head = Some("minecraft:tnt".into());
|
||||
assert!(prime(&mut m, false));
|
||||
assert!(m.invulnerable_flag);
|
||||
let (health, fuse_left) = (m.health, fuse(&m));
|
||||
m.damage(5.0, crate::mobs::Damage::player([2.0, 0.0, 0.0]));
|
||||
assert!(m.velocity.x < 0.0, "velocity {:?}", m.velocity);
|
||||
assert_eq!(m.health, health);
|
||||
assert_eq!(fuse(&m), fuse_left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_fuse_counts_down_without_going_negative() {
|
||||
let mut m = cube();
|
||||
@@ -740,4 +772,35 @@ mod fuse_anim_tests {
|
||||
assert_eq!(m.extra.counter2, 1);
|
||||
assert_eq!(fuse(&m), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rockets_burst_primes_a_tnt_cube_that_shrugs_it_off() {
|
||||
// `fireworks` is in #is_explosion, so it lights the short fuse
|
||||
// (`PrimedTnt.getRandomShortFuse`: 15 to 44 of 120 ticks), and so in
|
||||
// #sulfur_cube_with_block_immune_to; it is not in #no_knockback.
|
||||
load_archetype_items(&HashMap::from([(
|
||||
"minecraft:sulfur_cube_archetype/explosive".to_owned(),
|
||||
HashSet::from(["minecraft:tnt".to_owned()]),
|
||||
)]));
|
||||
let source = Damage::fireworks(Some(crate::mobs::Who::Player), [2.0, 0.0, 0.0]);
|
||||
assert_eq!(source.kind, DamageKind::Fireworks);
|
||||
assert!(!source.no_knockback());
|
||||
let mut m = cube();
|
||||
m.extra.head = Some("minecraft:tnt".into());
|
||||
let health = m.health;
|
||||
m.damage(7.0, source);
|
||||
assert!((15..45).contains(&fuse(&m)), "fuse {}", fuse(&m));
|
||||
assert_eq!(m.health, health);
|
||||
assert!(m.velocity.x < 0.0);
|
||||
// Without a block it takes the hit, records it and is knocked away
|
||||
// from the rocket.
|
||||
let mut m = cube();
|
||||
m.damage(1.0, source);
|
||||
assert_eq!(m.health, health - 1.0);
|
||||
assert_eq!(
|
||||
m.last_damage.map(|(d, _)| d.kind),
|
||||
Some(DamageKind::Fireworks)
|
||||
);
|
||||
assert!(m.velocity.x < 0.0);
|
||||
}
|
||||
}
|
||||
@@ -151,9 +151,13 @@ fn wolf_prey(s: &super::Seen) -> bool {
|
||||
fn baby_turtle_on_land(s: &super::Seen) -> bool {
|
||||
s.species == Species::Turtle && s.baby
|
||||
}
|
||||
/// `NeutralMob.isAngryAt(player)`: the player is the anger's target.
|
||||
fn angry_at_player(m: &Mob, _: &MobWorld) -> bool {
|
||||
/// `NeutralMob.isAngryAt(player)`: the player the anger names, or, with the
|
||||
/// `universal_anger` rule on and no anger target left, any player. Creative and
|
||||
/// spectator players are dropped first (`isValidPlayerTarget`), by the goal's
|
||||
/// `can_target_player`.
|
||||
fn angry_at_player(m: &Mob, w: &MobWorld) -> bool {
|
||||
m.extra.angry_at == Some(Who::Player)
|
||||
|| (w.universal_anger && m.extra.anger > 0 && m.extra.angry_at.is_none())
|
||||
}
|
||||
/// `Wolf.isAngry`.
|
||||
pub fn angry(m: &Mob) -> bool {
|
||||
@@ -1856,3 +1860,56 @@ mod tests {
|
||||
assert!(m.extra.sitting);
|
||||
}
|
||||
}
|
||||
|
||||
/// `NeutralMob.isAngryAtAllPlayers`: with the `universal_anger` game rule on, a
|
||||
/// wolf still angry with no target left goes for any player; with the rule off
|
||||
/// it holds none, and a creative player is never one (`isValidPlayerTarget`).
|
||||
#[cfg(test)]
|
||||
mod universal_anger_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::{test_player, with_test_world};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn universal_anger_targets_a_player_the_wolf_was_never_angered_by() {
|
||||
// (the `universal_anger` rule, the player is creative, whether the wolf
|
||||
// ends up with the player as its target).
|
||||
let cases = [
|
||||
(true, false, true),
|
||||
(false, false, false),
|
||||
(true, true, false),
|
||||
];
|
||||
with_test_world(&[], |w, types| {
|
||||
for (universal_anger, creative, targets) in cases {
|
||||
let mut w = w.clone();
|
||||
let mut p = test_player(Vec3::new(2.5, 64.0, 0.5));
|
||||
p.creative = creative;
|
||||
w.player = Some(p);
|
||||
w.universal_anger = universal_anger;
|
||||
let kind = Arc::new(types.types["minecraft:wolf"].clone());
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
// `anger_end_time` still running, `angry_at` gone: angry, with
|
||||
// nobody left that it was angered by.
|
||||
m.extra.anger = 600;
|
||||
m.extra.angry_at = None;
|
||||
let mut goals = std::mem::take(&mut m.ai.targets);
|
||||
let mut events = Vec::new();
|
||||
let mut lr = horizon_core::random::LegacyRandom::new(0);
|
||||
let mut cx = Cx {
|
||||
w: &w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
for _ in 0..200 {
|
||||
goals.tick(&mut m, &mut cx);
|
||||
}
|
||||
m.ai.targets = goals;
|
||||
assert_eq!(
|
||||
m.ai.target == Some(Who::Player),
|
||||
targets,
|
||||
"universal_anger {universal_anger}, creative {creative}"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -454,6 +454,7 @@ pub(crate) fn with_built_test_world(
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: super::spawning::LocalDifficulty::new(2, 18000, 0, 0.0),
|
||||
|
||||
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user