mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Merge main into #5643: the new Chat rows read the language table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
79b92a3f8f
208 files changed
+16439
-582
No files matched your search
Generated
+2
@@ -1395,6 +1395,7 @@ dependencies = [
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
@@ -1516,6 +1517,7 @@ name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
|
||||
@@ -66,3 +66,28 @@ with Microsoft accounts
|
||||
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
|
||||
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
|
||||
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
|
||||
|
||||
## CIT Resewn
|
||||
|
||||
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
|
||||
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
|
||||
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
|
||||
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2021 SHsuperCM
|
||||
>
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
> and associated documentation files (the "Software"), to deal in the Software without
|
||||
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
> Software is furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in all copies or
|
||||
> substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -281,6 +281,13 @@ pub struct Player {
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub sprinting: bool,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
# The narrator option's status and whether the narrator is there (Options.narrator).
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
|
||||
@@ -101,6 +101,8 @@ pub struct RenderAssets {
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -240,9 +242,34 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
if draws {
|
||||
let connected = if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
}
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
let catalog = &models.catalog;
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
root,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
sprites.finish();
|
||||
@@ -267,6 +294,7 @@ impl RenderAssets {
|
||||
pbr_normals: sprites.normals,
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
|
||||
/// How long a message shows before a deletion replaces it
|
||||
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
|
||||
const DELETION_DELAY: u64 = 60;
|
||||
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
|
||||
/// and how far left of its place it starts (GUI pixels).
|
||||
const INTRO_TICKS: f32 = 5.0;
|
||||
const INTRO_WIDTH: f32 = 12.0;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -233,14 +237,10 @@ impl Tag {
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
/// fading out quadratically over 20.
|
||||
fn time_alpha(ticks: u64, added: u64) -> f32 {
|
||||
let delta = ticks as i64 - added as i64;
|
||||
let mut t = delta as f64 / 200.0;
|
||||
t = 1.0 - t;
|
||||
t *= 10.0;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
t *= t;
|
||||
t as f32
|
||||
fn time_alpha(ticks: f32, added: u64) -> f32 {
|
||||
let mut t = 1.0 - (ticks - added as f32) / 200.0;
|
||||
t = (t * 10.0).clamp(0.0, 1.0);
|
||||
t * t
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Chat {
|
||||
@@ -256,6 +256,11 @@ pub struct Chat {
|
||||
scroll: usize,
|
||||
new_since_scroll: bool,
|
||||
pub options: ChatOptions,
|
||||
/// The smooth chat extra: a new line slides in from the left while it
|
||||
/// fades in, instead of appearing whole.
|
||||
pub smooth: bool,
|
||||
/// The smooth chat extra: no indicator bar at the left of a line.
|
||||
pub hide_indicator: bool,
|
||||
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
|
||||
pub log: bool,
|
||||
/// Messages added since the start, for the control socket's events.
|
||||
@@ -555,7 +560,7 @@ impl Chat {
|
||||
}
|
||||
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
|
||||
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
|
||||
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
|
||||
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
|
||||
let total = self.lines.len();
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
@@ -569,28 +574,47 @@ impl Chat {
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
// Vanilla's 200-tick fade reads the whole GUI ticks
|
||||
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
|
||||
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
|
||||
let alpha_of = |line: &Line| {
|
||||
if focused {
|
||||
1.0
|
||||
} else {
|
||||
time_alpha(ticks, line.added)
|
||||
time_alpha(fade_ticks, line.added)
|
||||
}
|
||||
};
|
||||
// The smooth chat extra: how far into its intro a line is, 0 at the
|
||||
// tick it was added and 1 once it has arrived, as (alpha, offset x).
|
||||
let intro_of = |line: &Line| {
|
||||
if focused || !self.smooth {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
|
||||
let eased = 1.0 - (1.0 - t) * (1.0 - t);
|
||||
(eased, (eased - 1.0) * INTRO_WIDTH)
|
||||
};
|
||||
let visible = total
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(focused));
|
||||
let lines: Vec<(usize, &Line, f32)> = (0..visible)
|
||||
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
|
||||
.rev()
|
||||
.map(|i| (i, &self.lines[i + self.scroll]))
|
||||
.map(|(i, line)| (i, line, alpha_of(line)))
|
||||
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
|
||||
.map(|(i, line)| {
|
||||
let (eased, slide) = intro_of(line);
|
||||
(i, line, alpha_of(line) * eased, slide)
|
||||
})
|
||||
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
|
||||
.collect();
|
||||
let mut out = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
for &(i, _, alpha) in &lines {
|
||||
for &(i, _, alpha, slide) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
@@ -598,9 +622,15 @@ impl Chat {
|
||||
bottom,
|
||||
hud_layout::as8(alpha * background_opacity) << 24,
|
||||
));
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
for &(i, line, alpha) in &lines {
|
||||
for &(i, line, alpha, slide) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
let opacity = alpha * text_opacity;
|
||||
let color = hud_layout::white(opacity);
|
||||
if color >> 24 != 0 {
|
||||
@@ -612,7 +642,7 @@ impl Chat {
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
if let Some(tag) = &line.tag {
|
||||
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
|
||||
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
@@ -622,6 +652,9 @@ impl Chat {
|
||||
color,
|
||||
));
|
||||
}
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
if focused {
|
||||
let count = lines.len() as i32;
|
||||
@@ -658,7 +691,7 @@ impl Chat {
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
|
||||
/// open the restricted prompt above the lines.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
if focused && self.abilities.any_restriction() {
|
||||
|
||||
@@ -39,6 +39,61 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
|
||||
let mut chat = Chat {
|
||||
smooth: true,
|
||||
..Chat::default()
|
||||
};
|
||||
chat.lines.push(Line {
|
||||
added: 0,
|
||||
content: Text::literal("hi"),
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
SYSTEM_INDICATOR,
|
||||
))),
|
||||
});
|
||||
let text_alpha = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Text { color, .. } => Some(color >> 24),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The slide is the negative translate the line carries while it moves.
|
||||
let slide = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
|
||||
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The tick it arrives: fully faded out, so nothing is drawn.
|
||||
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
|
||||
// Halfway through the intro: left of its place and still faint.
|
||||
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
|
||||
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
|
||||
let faded = text_alpha(&halfway).expect("the line shows halfway through");
|
||||
// Settled: in place and as opaque as vanilla's line.
|
||||
let settled = chat.layout(361, INTRO_TICKS, false);
|
||||
assert_eq!(slide(&settled), None);
|
||||
assert!(text_alpha(&settled).expect("settled") > faded);
|
||||
// The extra off draws the line whole on the tick it arrives, as vanilla.
|
||||
chat.smooth = false;
|
||||
let plain = chat.layout(361, 0.0, false);
|
||||
assert_eq!(slide(&plain), None);
|
||||
assert_eq!(text_alpha(&plain), text_alpha(&settled));
|
||||
// The indicator bar is the -4 to -2 fill at the left of the line.
|
||||
let bar = |draws: &[GuiDraw]| {
|
||||
draws
|
||||
.iter()
|
||||
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
|
||||
};
|
||||
assert!(bar(&plain));
|
||||
chat.hide_indicator = true;
|
||||
assert!(!bar(&chat.layout(361, 0.0, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
|
||||
let font = font();
|
||||
@@ -95,7 +150,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
|
||||
for scroll in [0, 3, 12] {
|
||||
chat.scroll = scroll;
|
||||
let mut links = 0;
|
||||
for draw in chat.layout(361, 0, true) {
|
||||
for draw in chat.layout(361, 0.0, true) {
|
||||
if let GuiDraw::Text { text, x, y, .. } = draw {
|
||||
let mut at = x as f64;
|
||||
for (c, style) in text.chars() {
|
||||
|
||||
@@ -0,0 +1,662 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
|
||||
//! `horizon_optifine::cit`'s.
|
||||
|
||||
pub use horizon_optifine::cit::{Hand, Kind, Rule};
|
||||
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
|
||||
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
|
||||
let bytes = fs::read(root.join(&path)).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
|
||||
root.join(p).is_file()
|
||||
});
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
rules
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CitRules {
|
||||
pub rules: Vec<Rule>,
|
||||
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
|
||||
by_item: [HashMap<String, Vec<u32>>; 4],
|
||||
/// Enchantment rules without `items`, in priority order.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
const CACHE_LIMIT: usize = 4096;
|
||||
|
||||
impl CitRules {
|
||||
/// The enabled packs' rules under the overlay resource root.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
Self::new(load_rules(root))
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
pub fn new(rules: Vec<Rule>) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
Self {
|
||||
rules,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
|
||||
pub fn select(
|
||||
&self,
|
||||
kind: Kind,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
}
|
||||
|
||||
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
|
||||
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, Hand::Any),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
let found = hit.unwrap_or_else(|| {
|
||||
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
|
||||
all.sort_unstable();
|
||||
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Option<Hand>,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
|
||||
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
let text = format!("texture=x\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
CitRules::new(rules)
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// Glints are never an item's texture.
|
||||
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,724 @@
|
||||
//! OptiFine connected textures (`optifine/ctm`) at mesh time. The active packs' rules are read
|
||||
//! once at load, their tiles become sprite layers, and each block state and sprite layer gets
|
||||
//! the short list of rules that can touch it; meshing a face then walks only those lists
|
||||
//! ([`ConnectedTextures::pick`]). Without CTM files there are no rules and nothing changes.
|
||||
//!
|
||||
//! The rules and the tile geometry are `horizon_optifine::ctm`'s.
|
||||
|
||||
use crate::sprites::Sprites;
|
||||
use horizon_optifine::ctm::{
|
||||
self, Connect, Method, Orient, Rule, Tile,
|
||||
connect::{self as geometry, Axes, Axis, Face},
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// A tile as the mesher draws it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TileLayer {
|
||||
Layer {
|
||||
layer: u16,
|
||||
/// The sprite has fractional alpha (drawn in the translucent pass).
|
||||
translucent: bool,
|
||||
/// Every texel is opaque (drawn without an alpha test).
|
||||
opaque: bool,
|
||||
},
|
||||
/// Try the next rule.
|
||||
Skip,
|
||||
/// The face's own sprite.
|
||||
Default,
|
||||
}
|
||||
|
||||
/// What a face draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pick {
|
||||
/// Its own sprite.
|
||||
Keep,
|
||||
Tile(TileLayer),
|
||||
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
|
||||
/// bottom-right).
|
||||
Quarters([TileLayer; 4]),
|
||||
}
|
||||
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct StateInfo {
|
||||
/// Index of the state's block (`connect=block`).
|
||||
block: u32,
|
||||
axis: Option<Axis>,
|
||||
/// `half=upper`: `linked` random tiles follow the lower half.
|
||||
upper: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ConnectedTextures {
|
||||
rules: Vec<LoadedRule>,
|
||||
/// By block state, then by sprite layer: ranges of `lists` (rule indices, in rule order).
|
||||
by_state: Vec<(u32, u32)>,
|
||||
by_layer: Vec<(u32, u32)>,
|
||||
lists: Vec<u16>,
|
||||
states: Vec<StateInfo>,
|
||||
/// By block state: the sprite of its model's face on each side (`connect=tile`); empty
|
||||
/// when no rule connects by tile.
|
||||
face_layers: Vec<[u16; 6]>,
|
||||
}
|
||||
|
||||
/// `list` appended to `lists`, as its range there.
|
||||
fn push_list(lists: &mut Vec<u16>, list: &[u16]) -> (u32, u32) {
|
||||
let start = lists.len() as u32;
|
||||
lists.extend_from_slice(list);
|
||||
(start, list.len() as u32)
|
||||
}
|
||||
|
||||
/// The sprite name of a texture resource path: `ns:path` under `textures/`, else the path
|
||||
/// itself (as [`Sprites::insert_file`] names a tile).
|
||||
fn sprite_name(resource: &str) -> String {
|
||||
let parts = resource
|
||||
.strip_prefix("assets/")
|
||||
.and_then(|p| p.split_once('/'))
|
||||
.and_then(|(ns, p)| Some((ns, p.strip_prefix("textures/")?.strip_suffix(".png")?)));
|
||||
match parts {
|
||||
Some((ns, path)) => format!("{ns}:{path}"),
|
||||
None => resource.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectedTextures {
|
||||
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
|
||||
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut rules = ctm::load(root);
|
||||
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
|
||||
// meshes see no block entity).
|
||||
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
|
||||
// Rule indices are `u16`.
|
||||
rules.truncate(usize::from(u16::MAX));
|
||||
if rules.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
let loaded = rules
|
||||
.into_iter()
|
||||
.filter_map(|rule| {
|
||||
let tiles = rule
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|tile| match tile {
|
||||
Tile::Skip => Ok(TileLayer::Skip),
|
||||
Tile::Default => Ok(TileLayer::Default),
|
||||
Tile::Texture(path) => {
|
||||
// A tile among the textures is that sprite, under its own name.
|
||||
let layer = match sprite_name(path) {
|
||||
name if name != *path => sprites.insert(root, &name)?,
|
||||
_ => sprites.insert_file(root, path)?,
|
||||
};
|
||||
Ok(TileLayer::Layer {
|
||||
layer: u16::try_from(layer)?,
|
||||
translucent: sprites.translucent[layer as usize],
|
||||
opaque: sprites.opaque[layer as usize],
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>();
|
||||
match tiles {
|
||||
Ok(tiles) => Some(LoadedRule { rule, tiles }),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let states = (0..registry.len() as u32).map(|id| {
|
||||
registry
|
||||
.state(BlockStateId(id))
|
||||
.map(|s| (s.name.as_str(), &s.properties))
|
||||
});
|
||||
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
|
||||
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
|
||||
out.face_layers = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
Face::ALL
|
||||
.map(|f| face_layer(BlockStateId(id), f).map_or(u16::MAX, |l| l as u16))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The lookups for `rules` (already ordered) over every block state (`None` for an id the
|
||||
/// registry skips), `layer` answering a sprite name's layer.
|
||||
fn build<'a>(
|
||||
rules: Vec<LoadedRule>,
|
||||
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
|
||||
layer: impl Fn(&str) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut out = Self {
|
||||
rules,
|
||||
..Self::default()
|
||||
};
|
||||
let mut blocks = HashMap::new();
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
|
||||
for (i, r) in out.rules.iter().enumerate() {
|
||||
let i = i as u16;
|
||||
for tile in &r.rule.match_tiles {
|
||||
if let Some(l) = layer(&sprite_name(tile)) {
|
||||
let l = l as usize;
|
||||
if by_layer.len() <= l {
|
||||
by_layer.resize(l + 1, Vec::new());
|
||||
}
|
||||
if by_layer[l].last() != Some(&i) {
|
||||
by_layer[l].push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &r.rule.match_blocks {
|
||||
let list = by_block.entry(m.id.as_str()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for list in by_layer {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_layer.push(range);
|
||||
}
|
||||
let mut list = Vec::new();
|
||||
for state in states {
|
||||
let Some((name, props)) = state else {
|
||||
out.by_state.push((0, 0));
|
||||
out.states.push(StateInfo::default());
|
||||
continue;
|
||||
};
|
||||
let next = blocks.len() as u32;
|
||||
let block = *blocks.entry(name).or_insert(next);
|
||||
let prop = |k: &str| props.get(k).map(String::as_str);
|
||||
out.states.push(StateInfo {
|
||||
block,
|
||||
axis: match prop("axis") {
|
||||
Some("x") => Some(Axis::X),
|
||||
Some("y") => Some(Axis::Y),
|
||||
Some("z") => Some(Axis::Z),
|
||||
_ => None,
|
||||
},
|
||||
upper: prop("half") == Some("upper"),
|
||||
});
|
||||
list.clear();
|
||||
if let Some(candidates) = by_block.get(name) {
|
||||
list.extend(
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
|
||||
);
|
||||
}
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_state.push(range);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
|
||||
ranges.get(i).map_or(&[], |&(start, len)| {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
})
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
|
||||
/// by rules that test one.
|
||||
pub fn pick<'b>(
|
||||
&self,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
pos: [i32; 3],
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
) -> Pick {
|
||||
let (mut by_state, mut by_layer) = (
|
||||
self.list(&self.by_state, state as usize),
|
||||
self.list(&self.by_layer, layer as usize),
|
||||
);
|
||||
// Both lists are in rule order: walk them as one. A rule naming blocks and tiles needs
|
||||
// both (it is in both lists); one naming either needs that one.
|
||||
loop {
|
||||
let (i, block, tile) = match (by_state.split_first(), by_layer.split_first()) {
|
||||
(None, None) => return Pick::Keep,
|
||||
(Some((&x, rest)), Some((&y, other))) if x == y => {
|
||||
(by_state, by_layer) = (rest, other);
|
||||
(x, true, true)
|
||||
}
|
||||
(Some((&x, rest)), Some((&y, _))) if x < y => {
|
||||
by_state = rest;
|
||||
(x, true, false)
|
||||
}
|
||||
(Some((&x, rest)), None) => {
|
||||
by_state = rest;
|
||||
(x, true, false)
|
||||
}
|
||||
(_, Some((&y, rest))) => {
|
||||
by_layer = rest;
|
||||
(y, false, true)
|
||||
}
|
||||
};
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
|
||||
{
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply<'b>(
|
||||
&self,
|
||||
r: &LoadedRule,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
pos: [i32; 3],
|
||||
neighbour: &impl Fn([i32; 3]) -> u32,
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
) -> Option<Pick> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
// a custom name are not loaded.
|
||||
if rule.faces & face.bit() == 0
|
||||
|| rule.heights.as_ref().is_some_and(|h| !h.contains(pos[1]))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(biomes) = &rule.biomes
|
||||
&& !biome().is_some_and(|b| biomes.matches(b))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let info = self.states.get(state as usize).copied().unwrap_or_default();
|
||||
let joins = |offset: [i32; 3]| {
|
||||
let other = neighbour(offset);
|
||||
match rule.connect {
|
||||
Connect::Block => {
|
||||
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
|
||||
}
|
||||
Connect::State => other == state,
|
||||
Connect::Tile => self
|
||||
.face_layers
|
||||
.get(other as usize)
|
||||
.is_some_and(|f| f[face as usize] == layer),
|
||||
}
|
||||
};
|
||||
let axes = |face| match (rule.orient, info.axis) {
|
||||
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
|
||||
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
|
||||
_ => Axes::cube(face),
|
||||
};
|
||||
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
|
||||
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
|
||||
let index = match rule.method {
|
||||
Method::Ctm => geometry::ctm_index(mask()),
|
||||
Method::CtmCompact => {
|
||||
let mask = mask();
|
||||
let case = geometry::ctm_index(mask) as u8;
|
||||
match rule.compact_overrides.iter().find(|&&(c, _)| c == case) {
|
||||
Some(&(_, t)) => t,
|
||||
None => {
|
||||
let quarters = geometry::compact_quarters(mask).map(|q| tile(q.into()));
|
||||
return if quarters.contains(&TileLayer::Skip) {
|
||||
None
|
||||
} else {
|
||||
Some(Pick::Quarters(quarters))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::Horizontal => geometry::horizontal(mask()),
|
||||
Method::Vertical => geometry::vertical(mask()),
|
||||
Method::HorizontalVertical => geometry::horizontal_vertical(mask()),
|
||||
Method::VerticalHorizontal => geometry::vertical_horizontal(mask()),
|
||||
Method::Top => {
|
||||
// Only sides join the block above: the top and bottom go on to the next rule.
|
||||
if matches!(face, Face::Up | Face::Down) {
|
||||
return None;
|
||||
}
|
||||
if !joins([0, 1, 0]) {
|
||||
return Some(Pick::Keep);
|
||||
}
|
||||
0
|
||||
}
|
||||
Method::Fixed => 0,
|
||||
Method::Random => {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
let pos = if rule.linked && info.upper {
|
||||
[pos[0], pos[1] - 1, pos[2]]
|
||||
} else {
|
||||
pos
|
||||
};
|
||||
geometry::random_index(pos, face, rule.random_loops, &rule.weights, r.tiles.len())
|
||||
}
|
||||
Method::Repeat => {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
|
||||
}
|
||||
// Overlays are not loaded yet.
|
||||
_ => return None,
|
||||
};
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
t => Some(Pick::Tile(t)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
/// A tile whose layer says which tile of its rule it is: rule `r`'s tile `i` is layer
|
||||
/// `1000 * (r + 1) + i`.
|
||||
fn rules(files: &[(&str, &str)]) -> Vec<LoadedRule> {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(path, text)| {
|
||||
Rule::parse(
|
||||
&format!("assets/minecraft/optifine/ctm/{path}"),
|
||||
&Properties::parse(text),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
ctm::sort(&mut rules);
|
||||
rules
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| match t {
|
||||
Tile::Skip => TileLayer::Skip,
|
||||
Tile::Default => TileLayer::Default,
|
||||
Tile::Texture(_) => TileLayer::Layer {
|
||||
layer: (1000 * (n + 1) + i) as u16,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
rule,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
|
||||
/// upper); the stone sprite is layer 7.
|
||||
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
|
||||
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
};
|
||||
let states = [
|
||||
("minecraft:air", props(&[])),
|
||||
("minecraft:glass", props(&[])),
|
||||
("minecraft:stone", props(&[])),
|
||||
("minecraft:oak_log", props(&[("axis", "y")])),
|
||||
("minecraft:oak_log", props(&[("axis", "x")])),
|
||||
("minecraft:oak_door", props(&[("half", "lower")])),
|
||||
("minecraft:oak_door", props(&[("half", "upper")])),
|
||||
];
|
||||
ConnectedTextures::build(
|
||||
rules(files),
|
||||
states.iter().map(|(n, p)| Some((*n, p))),
|
||||
|name| (name == "minecraft:block/stone").then_some(7),
|
||||
)
|
||||
}
|
||||
|
||||
fn tile(layer: u16) -> Pick {
|
||||
Pick::Tile(TileLayer::Layer {
|
||||
layer,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `pick` on `face` of the block at the origin among `blocks` (offset, state).
|
||||
fn pick_in(
|
||||
ctm: &ConnectedTextures,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
blocks: &[([i32; 3], u32)],
|
||||
) -> Pick {
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
|
||||
}
|
||||
|
||||
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
|
||||
|
||||
#[test]
|
||||
fn glass_joins_its_neighbours() {
|
||||
let ctm = world(&[("glass.properties", GLASS)]);
|
||||
// Alone: tile 0. A north face's right is west: glass there is the left end of a row.
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(1000));
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1001)
|
||||
);
|
||||
// Glass all around in the face's plane: the middle tile, 26.
|
||||
let ring: Vec<_> = [-1, 0, 1]
|
||||
.into_iter()
|
||||
.flat_map(|x| [-1, 0, 1].map(|y| ([x, y, 0], 1)))
|
||||
.collect();
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &ring), tile(1026));
|
||||
// Stone beside it doesn't join; stone has no rule.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_order_faces_and_skips() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a_sides.properties",
|
||||
"matchBlocks=stone\nmethod=fixed\ntiles=<skip>\nfaces=sides",
|
||||
),
|
||||
("b_glass.properties", GLASS),
|
||||
("stone.properties", "method=fixed\ntiles=x\nfaces=top"),
|
||||
("z.properties", "matchBlocks=stone\nmethod=fixed\ntiles=y"),
|
||||
]);
|
||||
// The tile rule (stone.properties, by its file name) comes first, on the top face only.
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), tile(1000));
|
||||
// Sides: a_sides skips, so z draws.
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), tile(4000));
|
||||
// The tile rule follows the sprite, whatever the block: glass with the stone sprite.
|
||||
assert_eq!(pick_in(&ctm, 1, 7, Face::Up, &[]), tile(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_quarters_and_overrides() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=ctm_compact\ntiles=0-5\nctm.1=5",
|
||||
)]);
|
||||
let layer = |i: u16| TileLayer::Layer {
|
||||
layer: 1000 + i,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[]),
|
||||
Pick::Quarters([layer(0); 4])
|
||||
);
|
||||
// Joined right only is case 1, replaced whole by tile 5.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1005)
|
||||
);
|
||||
// Joined left only (east of a north face): quarters [3, 0, 3, 0].
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([1, 0, 0], 1)]),
|
||||
Pick::Quarters([layer(3), layer(0), layer(3), layer(0)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_turn_with_their_axis() {
|
||||
let ctm = world(&[(
|
||||
"log.properties",
|
||||
"matchBlocks=oak_log\nmethod=vertical\ntiles=0-3",
|
||||
)]);
|
||||
// An upright log with a log above: the bottom of a column.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
|
||||
tile(1000)
|
||||
);
|
||||
// A log lying along x: on its north face the texture's up points east, so a log to the
|
||||
// east (same block, other axis state) is above it.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 4, 3, Face::North, &[([1, 0, 0], 4)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 4, 3, Face::North, &[([0, 1, 0], 4)]),
|
||||
tile(1003)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_and_connect_state() {
|
||||
let ctm = world(&[(
|
||||
"block_oak_log.properties",
|
||||
"method=top\ntiles=0\nconnect=state",
|
||||
)]);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
|
||||
tile(1000)
|
||||
);
|
||||
// The other axis is another state.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 4)]),
|
||||
Pick::Keep
|
||||
);
|
||||
assert_eq!(pick_in(&ctm, 3, 3, Face::Up, &[([0, 1, 0], 3)]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_random_follows_the_lower_half() {
|
||||
let ctm = world(&[(
|
||||
"door.properties",
|
||||
"matchBlocks=oak_door\nmethod=random\ntiles=0-7\nlinked=true",
|
||||
)]);
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
for x in 0..20 {
|
||||
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
|
||||
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
|
||||
assert_eq!(lower, upper, "{x}");
|
||||
tiles.insert(format!("{lower:?}"));
|
||||
}
|
||||
assert!(tiles.len() > 3, "the doors are not all alike");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heights_and_biomes() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
|
||||
)]);
|
||||
let at =
|
||||
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
|
||||
assert_eq!(at(10, "minecraft:desert"), tile(1000));
|
||||
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
|
||||
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_by_tile_reads_the_neighbours_same_side() {
|
||||
let mut ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=horizontal\nconnect=tile\ntiles=0-3",
|
||||
)]);
|
||||
// Stone shows the glass sprite (3) on its north side; the door only on its south.
|
||||
let mut faces = vec![[9u16; 6]; 7];
|
||||
faces[1][Face::North as usize] = 3;
|
||||
faces[2][Face::North as usize] = 3;
|
||||
faces[5][Face::South as usize] = 3;
|
||||
ctm.face_layers = faces;
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 5)]),
|
||||
tile(1003)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_and_rows_then_columns() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
|
||||
)]);
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
|
||||
assert_eq!(at([0, 64, 0]), tile(1000));
|
||||
// The north face's right is west; rows run down.
|
||||
assert_eq!(at([-1, 64, 0]), tile(1001));
|
||||
assert_eq!(at([0, 63, 0]), tile(1002));
|
||||
let hv = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=horizontal+vertical\ntiles=0-6",
|
||||
)]);
|
||||
// Alone in its row, glass above: the column's bottom, tile 4.
|
||||
assert_eq!(
|
||||
pick_in(&hv, 1, 3, Face::North, &[([0, 1, 0], 1)]),
|
||||
tile(1004)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&hv, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_defaults_and_narrowed_rules() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a.properties",
|
||||
"matchBlocks=glass\nmethod=ctm_compact\ntiles=<skip> 1-4",
|
||||
),
|
||||
(
|
||||
"b.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=sides",
|
||||
),
|
||||
(
|
||||
"c.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=<default>",
|
||||
),
|
||||
("d.properties", "matchBlocks=glass\nmethod=fixed\ntiles=0"),
|
||||
(
|
||||
"e.properties",
|
||||
"matchBlocks=stone\nmatchTiles=glass_pane_top\nmethod=fixed\ntiles=0",
|
||||
),
|
||||
]);
|
||||
// e names a tile, so it is tried first (tile 1000s), but needs a stone with the pane
|
||||
// sprite. a's skipped quarter drops it; b (3000s) draws the sides; on top c draws the
|
||||
// glass itself, which stops d.
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(3000));
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), Pick::Keep);
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), Pick::Keep);
|
||||
let both = world(&[(
|
||||
"e.properties",
|
||||
"matchBlocks=glass\nmatchTiles=stone\nmethod=fixed\ntiles=0",
|
||||
)]);
|
||||
assert_eq!(pick_in(&both, 1, 7, Face::Up, &[]), tile(1000));
|
||||
assert_eq!(pick_in(&both, 1, 3, Face::Up, &[]), Pick::Keep);
|
||||
assert_eq!(pick_in(&both, 2, 7, Face::Up, &[]), Pick::Keep);
|
||||
// `top` passes the top face on.
|
||||
let top = world(&[
|
||||
("block_glass.properties", "method=top\ntiles=0"),
|
||||
(
|
||||
"z.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=top",
|
||||
),
|
||||
]);
|
||||
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_names() {
|
||||
assert_eq!(
|
||||
sprite_name("assets/minecraft/textures/block/dirt.png"),
|
||||
"minecraft:block/dirt"
|
||||
);
|
||||
assert_eq!(
|
||||
sprite_name("assets/minecraft/optifine/ctm/glass/0.png"),
|
||||
"assets/minecraft/optifine/ctm/glass/0.png"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,10 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
|
||||
pub chat_animation: bool,
|
||||
/// The indicator bar at the left of a chat line (on by default, as vanilla).
|
||||
pub chat_indicator: bool,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
@@ -152,6 +156,8 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
chat_animation: false,
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
@@ -230,6 +236,8 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
@@ -241,7 +249,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -274,6 +282,8 @@ impl Extras {
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
bool_text(self.chat_animation),
|
||||
bool_text(self.chat_indicator),
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
@@ -406,6 +416,10 @@ pub enum Item {
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
/// A new chat line slides in and fades in (`Chat::layout`).
|
||||
ChatAnimation,
|
||||
/// The coloured bar at the left of a chat line.
|
||||
ChatIndicator,
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
@@ -430,6 +444,9 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(MouseTweaksWheel),
|
||||
Ok(Sort),
|
||||
Ok(SortKey),
|
||||
Err("horizon.extras.header.chat"),
|
||||
Ok(ChatAnimation),
|
||||
Ok(ChatIndicator),
|
||||
Err("horizon.extras.header.multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("horizon.extras.header.sound"),
|
||||
@@ -454,6 +471,8 @@ impl Item {
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
@@ -473,6 +492,8 @@ impl Item {
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
@@ -497,6 +518,8 @@ impl Item {
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
Item::SortKey => "Sort key",
|
||||
Item::ChatAnimation => "Smooth chat",
|
||||
Item::ChatIndicator => "Message indicator",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
@@ -510,8 +533,10 @@ impl Item {
|
||||
UiSoundReserve => return None,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -547,6 +572,10 @@ impl Item {
|
||||
Item::SortKey => {
|
||||
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
|
||||
}
|
||||
Item::ChatAnimation => {
|
||||
"A new chat line slides in from the left while it fades in, instead of appearing whole."
|
||||
}
|
||||
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
@@ -568,6 +597,7 @@ impl Item {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::UiSoundReserve => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::ChatAnimation => Some(Cost::None),
|
||||
Item::OwnNameTag
|
||||
| Item::HideBlockParticles
|
||||
| Item::AutoReconnect
|
||||
@@ -589,6 +619,8 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::ChatAnimation => e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
@@ -619,6 +651,8 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
@@ -681,6 +715,8 @@ mod tests {
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
|
||||
@@ -142,6 +142,7 @@ pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod stars;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
mod timing;
|
||||
pub mod translucency;
|
||||
@@ -164,6 +165,50 @@ fn sky_visible(frame: &Frame) -> bool {
|
||||
!frame.title && frame.skybox && sky_open(frame)
|
||||
}
|
||||
|
||||
/// The world pass's part that draws a shader pack's solid terrain: the world's colour and
|
||||
/// depth loaded, and its `surface` target, cleared on the first of these passes.
|
||||
fn surface_pass<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
surface: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
clear: bool,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
let keep = |load| wgpu::Operations {
|
||||
load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
};
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world, pack terrain"),
|
||||
color_attachments: &[
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: keep(wgpu::LoadOp::Load),
|
||||
}),
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view: surface,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: keep(if clear {
|
||||
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
|
||||
} else {
|
||||
wgpu::LoadOp::Load
|
||||
}),
|
||||
}),
|
||||
],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn world_pass_after_culling<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
@@ -618,7 +663,11 @@ pub struct Renderer {
|
||||
display_fresh: bool,
|
||||
window: Arc<Window>,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
/// Frames are finished and submitted on its submit thread ([`submit`]).
|
||||
pub queue: submit::Queue,
|
||||
/// The last frame went to the submit thread; whether it had no window surface.
|
||||
/// The next frame takes it in ([`Self::land_frame`]).
|
||||
deferred: Option<bool>,
|
||||
/// Set by the device's lost callback. wgpu reports nothing else when an
|
||||
/// operation fails on a lost device (no error scope sees it).
|
||||
device_lost: Arc<std::sync::atomic::AtomicBool>,
|
||||
@@ -640,6 +689,9 @@ pub struct Renderer {
|
||||
sides_on: bool,
|
||||
sky: wgpu::RenderPipeline,
|
||||
dark_disc: wgpu::RenderPipeline,
|
||||
/// Clears the world pass's depth to the far plane so the first-person hand draws in the
|
||||
/// same pass (`hand_in_world`), as the hand's own pass would clear it.
|
||||
depth_reset: wgpu::RenderPipeline,
|
||||
lines: wgpu::RenderPipeline,
|
||||
lines_secondary: wgpu::RenderPipeline,
|
||||
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
|
||||
@@ -2039,6 +2091,40 @@ impl Renderer {
|
||||
false,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
let depth_reset = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Hand depth reset"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_sky"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_depth_reset"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &sprite_constants,
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let lines = pipeline(
|
||||
"Block outline",
|
||||
"vs_line",
|
||||
@@ -3358,7 +3444,8 @@ impl Renderer {
|
||||
last_encode: std::time::Duration::ZERO,
|
||||
window,
|
||||
device,
|
||||
queue,
|
||||
queue: submit::Queue::new(queue, submit::wanted()),
|
||||
deferred: None,
|
||||
device_lost,
|
||||
config,
|
||||
solid,
|
||||
@@ -3372,6 +3459,7 @@ impl Renderer {
|
||||
flat,
|
||||
sky,
|
||||
dark_disc,
|
||||
depth_reset,
|
||||
lines,
|
||||
lines_secondary,
|
||||
debug_lines,
|
||||
@@ -3531,6 +3619,14 @@ impl Renderer {
|
||||
present_modes,
|
||||
})
|
||||
}
|
||||
/// The first-person hand's quads, which follow `first` quads of the entity buffer.
|
||||
fn draw_hand(&self, pass: &mut wgpu::RenderPass<'_>, quads: u32, first: u32) {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.hand_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
}
|
||||
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
|
||||
/// secondary stroke first when high contrast is on.
|
||||
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
|
||||
@@ -3578,6 +3674,7 @@ impl Renderer {
|
||||
/// Hand the window surface over to the renderer that replaces this one on
|
||||
/// the same device (it takes `spare_surface`).
|
||||
pub fn release_surface(&mut self) {
|
||||
self.queue.wait();
|
||||
*self
|
||||
.spare_surface
|
||||
.get_mut()
|
||||
@@ -3585,6 +3682,7 @@ impl Renderer {
|
||||
}
|
||||
/// Reattach a window after surface loss or a failed renderer replacement.
|
||||
pub fn recreate_surface(&mut self) -> Result<()> {
|
||||
self.queue.wait();
|
||||
if self.offscreen.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -4305,6 +4403,8 @@ impl Renderer {
|
||||
/// Configures the window surface again. One that fails is still the one
|
||||
/// wgpu configured before, which acquisition reports as a validation error.
|
||||
fn configure_surface(&self) {
|
||||
// The submit thread may still present to the old swapchain.
|
||||
self.queue.wait();
|
||||
if let Some(surface) = &self.surface
|
||||
&& let Err(e) = Self::configure(
|
||||
&self.device,
|
||||
@@ -4944,6 +5044,7 @@ impl Renderer {
|
||||
}
|
||||
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
|
||||
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
|
||||
self.land_frame();
|
||||
// World frames alone follow the shader pack: the game loads the picked one
|
||||
// before it draws one. Title frames draw no terrain and keep the layout
|
||||
// through a pack dropped (Shader Options) or not loaded yet (`Renderer::new`),
|
||||
@@ -6191,6 +6292,14 @@ impl Renderer {
|
||||
if culling && let Some(cull) = &mut self.cull {
|
||||
cull.bind_depth(&self.device, world_depth);
|
||||
}
|
||||
// The hand draws at the end of the world pass, after a depth reset, when nothing runs
|
||||
// between them (a scaled world's post pass, HDR's world copy, a native mod's
|
||||
// `native_world_drawn`): one render pass fewer, ~10 µs of wgpu's encoding a frame.
|
||||
let hand_in_world = (frame.hand.len() / 4) > 0
|
||||
&& !pack_draws_hand
|
||||
&& scaled.is_none()
|
||||
&& self.hdr.is_none()
|
||||
&& !native_render::world_drawn_hooked();
|
||||
if oit_on {
|
||||
let size = self
|
||||
.world_post
|
||||
@@ -6519,6 +6628,16 @@ impl Renderer {
|
||||
.frame
|
||||
.fit_opaque(&self.device, world_depth.texture(), color);
|
||||
}
|
||||
if let Some(native) = self.native.as_mut().filter(|_| !frame.title) {
|
||||
let writes = native.material.as_ref().is_some_and(|m| m.writes_surface);
|
||||
let depth = world_depth.texture();
|
||||
let size = if writes {
|
||||
[depth.width(), depth.height()]
|
||||
} else {
|
||||
[1, 1]
|
||||
};
|
||||
native.frame.fit_surface(&self.device, size);
|
||||
}
|
||||
// One of Horizon's own packs with a material stage: terrain draws with its pipelines,
|
||||
// which read the pack's frame at group 3.
|
||||
let material = self
|
||||
@@ -6541,6 +6660,17 @@ impl Renderer {
|
||||
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
|
||||
}
|
||||
};
|
||||
// A pack's solid terrain that calls `set_surface` also writes the surface target, in
|
||||
// passes of its own between the sky and everything else.
|
||||
let surface = self
|
||||
.native
|
||||
.as_ref()
|
||||
.filter(|n| {
|
||||
!frame.title
|
||||
&& !pack_draws_terrain
|
||||
&& n.material.as_ref().is_some_and(|m| m.writes_surface)
|
||||
})
|
||||
.map(|n| n.frame.surface_view.clone());
|
||||
{
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: world_view,
|
||||
@@ -6626,6 +6756,28 @@ impl Renderer {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
let terrain_pass =
|
||||
|encoder: &mut wgpu::CommandEncoder, surface: &wgpu::TextureView, clear: bool| {
|
||||
// Its lifetime forgotten: a closure cannot tie it to `encoder`'s.
|
||||
let mut pass = surface_pass(encoder, world_view, surface, world_depth, clear)
|
||||
.forget_lifetime();
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass
|
||||
};
|
||||
// The occlusion test's pipeline draws into the world's colour alone.
|
||||
let plain_pass = |encoder: &mut wgpu::CommandEncoder| {
|
||||
let mut pass =
|
||||
world_pass_after_culling(encoder, world_view, world_depth, true, None, "")
|
||||
.forget_lifetime();
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass
|
||||
};
|
||||
if let Some(surface) = &surface {
|
||||
drop(pass);
|
||||
pass = terrain_pass(&mut encoder, surface, true);
|
||||
}
|
||||
match self
|
||||
.flat
|
||||
.as_ref()
|
||||
@@ -6654,18 +6806,25 @@ impl Renderer {
|
||||
// that the test saw, copied after it.
|
||||
Some(cull) => {
|
||||
draw(&mut pass, &flat.calls[..flat.phase1_calls]);
|
||||
if surface.is_some() {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
}
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder, self.own_timing.as_mut());
|
||||
flat.flatten_phase2(&mut encoder, self.own_timing.as_mut());
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
);
|
||||
pass = match &surface {
|
||||
Some(surface) => terrain_pass(&mut encoder, surface, false),
|
||||
None => world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
),
|
||||
};
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
for &pipeline in passes {
|
||||
@@ -6697,17 +6856,24 @@ impl Renderer {
|
||||
pass.set_pipeline(pipeline);
|
||||
self.draw_sections(&mut pass, &self.indirect, 0, 0, phase1_draws);
|
||||
}
|
||||
if surface.is_some() {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
}
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder, self.own_timing.as_mut());
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
);
|
||||
pass = match &surface {
|
||||
Some(surface) => terrain_pass(&mut encoder, surface, false),
|
||||
None => world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
keep_depth,
|
||||
self.own_timing.as_mut(),
|
||||
"World after culling",
|
||||
),
|
||||
};
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pack_frame(&mut pass);
|
||||
pass.set_index_buffer(
|
||||
@@ -6735,6 +6901,11 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
if surface.is_some() {
|
||||
drop(pass);
|
||||
pass = plain_pass(&mut encoder);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
}
|
||||
if entity_quads > 0 {
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
@@ -7197,6 +7368,12 @@ impl Renderer {
|
||||
0..1,
|
||||
);
|
||||
}
|
||||
if hand_in_world {
|
||||
pass.set_pipeline(&self.depth_reset);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
|
||||
}
|
||||
}
|
||||
if let Some((color, depth)) = &scaled
|
||||
&& pack_post
|
||||
@@ -7317,7 +7494,7 @@ impl Renderer {
|
||||
post.run(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
(color, depth),
|
||||
(color, depth, &native.frame.surface_view),
|
||||
&view_target,
|
||||
size,
|
||||
history_reset,
|
||||
@@ -7358,7 +7535,7 @@ impl Renderer {
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(&mut encoder, self.own_timing.as_mut());
|
||||
}
|
||||
if hand_quads > 0 && !pack_draws_hand {
|
||||
if hand_quads > 0 && !pack_draws_hand && !hand_in_world {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
resolve_target: None,
|
||||
@@ -7382,15 +7559,7 @@ impl Renderer {
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.entity_pipeline);
|
||||
pass.set_bind_group(0, &self.hand_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(
|
||||
0..hand_quads * 6,
|
||||
((world_quads + additive_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(&mut encoder, self.own_timing.as_mut());
|
||||
@@ -7578,15 +7747,54 @@ impl Renderer {
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some(copy_frame(&self.device, &mut encoder, &texture, size))
|
||||
});
|
||||
let commands = crate::spans::time("encode", || encoder.finish());
|
||||
let first_frame = self.first_frame == FirstFrame::Submit;
|
||||
// Finished, submitted and presented on the submit thread when nothing after the
|
||||
// submission needs this one: screenshots' readbacks, the error scopes (one per
|
||||
// thread), the first frame's startup log, VSync's frame callback, a reconfigure.
|
||||
let (encoder, surface_frame, after) = if readback.is_none()
|
||||
&& hdr_readback.is_none()
|
||||
&& checks.is_none()
|
||||
&& !first_frame
|
||||
&& !reconfigure
|
||||
&& self.hdr_marker.is_none()
|
||||
&& (surface_frame.is_none() || self.config.present_mode != wgpu::PresentMode::Fifo)
|
||||
{
|
||||
let offscreen = surface_frame.is_none();
|
||||
// Culling reads its results back as soon as the frame is submitted, as here.
|
||||
let after = self
|
||||
.cull
|
||||
.as_mut()
|
||||
.and_then(|c| c.submitted())
|
||||
.map(|f| Box::new(f) as submit::AfterSubmit);
|
||||
let job = submit::Job {
|
||||
encoder,
|
||||
surface: surface_frame,
|
||||
after,
|
||||
};
|
||||
match self.queue.submit_later(job) {
|
||||
Ok(()) => {
|
||||
self.deferred = Some(offscreen);
|
||||
self.last_encode = started.elapsed().saturating_sub(self.last_wait);
|
||||
self.visible = visible;
|
||||
self.visible_lods = visible_lods;
|
||||
self.shadow_visible = shadow_visible;
|
||||
return Ok(true);
|
||||
}
|
||||
Err(back) => (back.encoder, back.surface, back.after),
|
||||
}
|
||||
} else {
|
||||
(encoder, surface_frame, None)
|
||||
};
|
||||
let commands = crate::spans::time("encode", || encoder.finish());
|
||||
if first_frame {
|
||||
startup_step(format_args!("submitting the first frame"));
|
||||
self.first_frame = FirstFrame::Drawn;
|
||||
}
|
||||
let submission = crate::spans::time("submit", || self.queue.submit([commands]));
|
||||
if let Some(cull) = &mut self.cull {
|
||||
cull.submitted();
|
||||
if let Some(read) = after {
|
||||
read();
|
||||
} else if let Some(read) = self.cull.as_mut().and_then(|c| c.submitted()) {
|
||||
read();
|
||||
}
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.submitted(&self.queue);
|
||||
@@ -7734,8 +7942,33 @@ pub(crate) fn copy_frame(
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
|
||||
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
|
||||
fn land_frame(&mut self) {
|
||||
let Some(offscreen) = self.deferred.take() else {
|
||||
return;
|
||||
};
|
||||
let submission = self.queue.landed();
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.submitted(&self.queue);
|
||||
timer.collect(&self.device, &mut self.gpu_times);
|
||||
}
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if offscreen && let Some(submission) = submission {
|
||||
self.in_flight.push_back(submission);
|
||||
if !self.paced_elsewhere {
|
||||
self.wait_frames();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// No swapchain paces offscreen frames: keep two on the GPU at most.
|
||||
pub fn wait_in_flight(&mut self) {
|
||||
self.land_frame();
|
||||
self.wait_frames();
|
||||
}
|
||||
fn wait_frames(&mut self) {
|
||||
while self.in_flight.len() > 2 {
|
||||
let index = self.in_flight.pop_front();
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
|
||||
@@ -585,23 +585,25 @@ impl Cull {
|
||||
self.current = Some(self.next);
|
||||
}
|
||||
|
||||
/// After the frame's submission: start reading its results back.
|
||||
pub fn submitted(&mut self) {
|
||||
let Some(i) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
/// The frame's results to read back, started by calling it right after the frame's
|
||||
/// submission (on the submit thread, when the frame goes there).
|
||||
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
|
||||
let i = self.current.take()?;
|
||||
let r = &self.readbacks[i];
|
||||
let state = r.state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
r.buffer
|
||||
.slice(..r.slots.len() as u64 * 4)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
|
||||
self.next = (i + 1) % READBACKS;
|
||||
Some(move || {
|
||||
buffer
|
||||
.slice(..bytes)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn make_group(
|
||||
|
||||
@@ -139,17 +139,32 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
|
||||
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
|
||||
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
|
||||
let normal = horizon_material_normal(in.position);
|
||||
horizon_material_face_normal = normal;
|
||||
horizon_surface = vec4<f32>(0.0);
|
||||
let tangent = horizon_material_tangent(in.position, in.uv, normal);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent)).color;
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@location(0) color: vec4<f32>,
|
||||
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
|
||||
// stage's `surface_at` compares with the final depth.
|
||||
@location(1) surface: vec4<f32>,
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
let color = horizon_material_color(in, false);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
if horizon_surface.w == 0.0 {
|
||||
set_surface(horizon_material_face_normal, 0.0, 0.0);
|
||||
}
|
||||
var out: HorizonMaterialSolid;
|
||||
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
out.surface = vec4(horizon_surface.xyz, in.clip.z);
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
@@ -189,6 +204,8 @@ pub struct NativeMaterial {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
|
||||
pub writes_surface: bool,
|
||||
}
|
||||
|
||||
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
|
||||
@@ -220,6 +237,10 @@ fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`.
|
||||
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
|
||||
/// bits), y smoothness, z the pack's data, w the fragment's depth.
|
||||
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
|
||||
@@ -250,6 +271,7 @@ impl NativeMaterial {
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
) -> Result<Self, String> {
|
||||
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
@@ -296,6 +318,18 @@ impl NativeMaterial {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
let solid_targets = [
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: SURFACE_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&stage.label),
|
||||
layout: Some(layout),
|
||||
@@ -321,11 +355,11 @@ impl NativeMaterial {
|
||||
module: &module,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: options,
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
|
||||
&solid_targets
|
||||
} else {
|
||||
&solid_targets[..1]
|
||||
},
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
@@ -370,6 +404,7 @@ impl NativeMaterial {
|
||||
solid,
|
||||
translucent,
|
||||
flat,
|
||||
writes_surface,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::Frame;
|
||||
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// The world's colour and depth views a set of bind groups was made for.
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
|
||||
|
||||
/// Where a post pass draws.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -103,6 +103,9 @@ pub struct NativePost {
|
||||
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
|
||||
/// what the material stage reads besides.
|
||||
pub struct NativeFrame {
|
||||
/// The solid terrain's second target, the post stage's `scene_surface`.
|
||||
surface: wgpu::Texture,
|
||||
pub surface_view: wgpu::TextureView,
|
||||
pub(super) buffer: wgpu::Buffer,
|
||||
/// Group 3 of the sky and material stages.
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
@@ -270,6 +273,8 @@ impl NativeFrame {
|
||||
label: Some("Native shader pack frame"),
|
||||
entries: &entries,
|
||||
});
|
||||
let surface = surface_target(device, [1, 1]);
|
||||
let surface_view = surface.create_view(&Default::default());
|
||||
let opaque_depth = depth_copy(device, [1, 1]);
|
||||
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
|
||||
let blank = copy_target(
|
||||
@@ -332,6 +337,8 @@ impl NativeFrame {
|
||||
buffer,
|
||||
layout,
|
||||
group,
|
||||
surface,
|
||||
surface_view,
|
||||
opaque_depth,
|
||||
opaque_color,
|
||||
reflection: blank.clone(),
|
||||
@@ -468,6 +475,14 @@ impl NativeFrame {
|
||||
self.regroup(device);
|
||||
}
|
||||
|
||||
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
|
||||
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
if [self.surface.width(), self.surface.height()] != size {
|
||||
self.surface = surface_target(device, size);
|
||||
self.surface_view = self.surface.create_view(&Default::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
|
||||
/// before the stages read it.
|
||||
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
|
||||
@@ -573,6 +588,24 @@ pub fn present_constants<'a>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The solid terrain's surface target of `size`, drawn and read.
|
||||
fn surface_target(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack surface"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: super::native_material::SURFACE_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -652,6 +685,7 @@ impl NativePost {
|
||||
texture_entry(8, float),
|
||||
texture_entry(9, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(10, float),
|
||||
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
];
|
||||
entries.extend(pack_texture_entries(frame.textures.len()));
|
||||
entries
|
||||
@@ -757,7 +791,7 @@ impl NativePost {
|
||||
fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
|
||||
size: [u32; 2],
|
||||
history_reset: bool,
|
||||
) {
|
||||
@@ -830,8 +864,11 @@ impl NativePost {
|
||||
if self
|
||||
.groups
|
||||
.as_ref()
|
||||
.is_some_and(|((color, depth), blank, _)| {
|
||||
color == world.0 && depth == world.1 && *blank == blank_history
|
||||
.is_some_and(|((color, depth, surface), blank, _)| {
|
||||
color == world.0
|
||||
&& depth == world.1
|
||||
&& surface == world.2
|
||||
&& *blank == blank_history
|
||||
})
|
||||
{
|
||||
return;
|
||||
@@ -893,6 +930,10 @@ impl NativePost {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(world.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
|
||||
@@ -920,7 +961,11 @@ impl NativePost {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
|
||||
self.groups = Some((
|
||||
(world.0.clone(), world.1.clone(), world.2.clone()),
|
||||
blank_history,
|
||||
groups,
|
||||
));
|
||||
}
|
||||
|
||||
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
|
||||
@@ -929,7 +974,7 @@ impl NativePost {
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
|
||||
screen: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
history_reset: bool,
|
||||
@@ -1349,7 +1394,7 @@ mod tests {
|
||||
post.run(
|
||||
&device,
|
||||
&mut encoder,
|
||||
(&color, &depth),
|
||||
(&color, &depth, &frame.surface_view),
|
||||
&view,
|
||||
[4, 4],
|
||||
true,
|
||||
@@ -1456,7 +1501,7 @@ file = \"b.wgsl\"
|
||||
post.run(
|
||||
&device,
|
||||
&mut encoder,
|
||||
(&color, &depth),
|
||||
(&color, &depth, &frame.surface_view),
|
||||
&view,
|
||||
[4, 4],
|
||||
history_reset,
|
||||
|
||||
@@ -6,6 +6,19 @@
|
||||
use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The game was built with native mods (the game sets it at start): one of them may draw in
|
||||
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
|
||||
pub fn set_mods_built_in(on: bool) {
|
||||
MODS_BUILT_IN.store(on, Ordering::Relaxed);
|
||||
}
|
||||
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
|
||||
pub fn world_drawn_hooked() -> bool {
|
||||
MODS_BUILT_IN.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
|
||||
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//! The frame's commands finished, submitted and presented on a thread of their own.
|
||||
//!
|
||||
//! wgpu records a pass's commands lazily: `CommandEncoder::finish` validates and
|
||||
//! encodes the whole frame (about 0.1 ms with the submit), work the window's thread
|
||||
//! would otherwise wait for before building the next frame. The renderer hands the
|
||||
//! frame's encoder to [`Queue::submit_later`] and goes on; every use of the queue
|
||||
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
|
||||
//! submissions keep the order they had on one thread.
|
||||
use std::ops::Deref;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
|
||||
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
|
||||
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
|
||||
/// macOS's Metal it stalled: offscreen at render distance 12, a tenth to a half of the frame rate
|
||||
/// with 20-180 ms p99 frames, against the same binary without it. Windows is not measured yet.
|
||||
pub fn wanted() -> bool {
|
||||
match std::env::var("HORIZON_SUBMIT_THREAD").as_deref() {
|
||||
Ok("0") => false,
|
||||
Ok("1") => true,
|
||||
_ => cfg!(target_os = "linux"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Work to start right after a frame's submission (a readback's `map_async`).
|
||||
pub type AfterSubmit = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// A frame for the submit thread: its commands and, in a window, the surface to present.
|
||||
pub struct Job {
|
||||
pub encoder: wgpu::CommandEncoder,
|
||||
pub surface: Option<wgpu::SurfaceTexture>,
|
||||
pub after: Option<AfterSubmit>,
|
||||
}
|
||||
|
||||
struct Thread {
|
||||
jobs: Option<mpsc::Sender<Job>>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// What the submit thread sent back, once it is taken in by [`Queue::wait`].
|
||||
struct Landing {
|
||||
done: mpsc::Receiver<wgpu::SubmissionIndex>,
|
||||
landed: Option<wgpu::SubmissionIndex>,
|
||||
}
|
||||
|
||||
/// The device's queue, with frames submitted on the submit thread when it runs.
|
||||
pub struct Queue {
|
||||
queue: wgpu::Queue,
|
||||
thread: Option<Thread>,
|
||||
/// A frame went to the submit thread and has not been waited for.
|
||||
pending: AtomicBool,
|
||||
landing: Mutex<Option<Landing>>,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
/// `threaded`: frames may go to a submit thread (the renderer asks [`wanted`]).
|
||||
pub fn new(queue: wgpu::Queue, threaded: bool) -> Self {
|
||||
let mut this = Self {
|
||||
queue,
|
||||
thread: None,
|
||||
pending: AtomicBool::new(false),
|
||||
landing: Mutex::new(None),
|
||||
};
|
||||
if !threaded {
|
||||
return this;
|
||||
}
|
||||
let (jobs, job_rx) = mpsc::channel::<Job>();
|
||||
let (done_tx, done) = mpsc::channel();
|
||||
let queue = this.queue.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Render submit".into())
|
||||
.spawn(move || {
|
||||
for job in job_rx {
|
||||
let commands = job.encoder.finish();
|
||||
let submission = queue.submit([commands]);
|
||||
if let Some(after) = job.after {
|
||||
after();
|
||||
}
|
||||
if let Some(surface) = job.surface {
|
||||
queue.present(surface);
|
||||
}
|
||||
if done_tx.send(submission).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
Ok(handle) => {
|
||||
this.thread = Some(Thread {
|
||||
jobs: Some(jobs),
|
||||
handle: Some(handle),
|
||||
});
|
||||
*this.lock() = Some(Landing { done, landed: None });
|
||||
}
|
||||
Err(e) => log::warn!("Horizon renderer: no submit thread ({e}); submitting in place"),
|
||||
}
|
||||
this
|
||||
}
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Landing>> {
|
||||
self.landing
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
/// Whether frames can go to the submit thread.
|
||||
pub fn threaded(&self) -> bool {
|
||||
self.thread.is_some()
|
||||
}
|
||||
/// Finish, submit and present the frame on the submit thread, then run its `after`;
|
||||
/// given back, with nothing done, when there is none.
|
||||
pub fn submit_later(&self, job: Job) -> Result<(), Box<Job>> {
|
||||
let Some(jobs) = self.thread.as_ref().and_then(|t| t.jobs.as_ref()) else {
|
||||
return Err(Box::new(job));
|
||||
};
|
||||
self.wait();
|
||||
match jobs.send(job) {
|
||||
Ok(()) => {
|
||||
self.pending.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
Err(mpsc::SendError(job)) => Err(Box::new(job)),
|
||||
}
|
||||
}
|
||||
/// Wait until the frame on the submit thread is submitted and presented.
|
||||
pub fn wait(&self) {
|
||||
if !self.pending.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let mut landing = self.lock();
|
||||
if !self.pending.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if let Some(l) = landing.as_mut() {
|
||||
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
|
||||
let submission = l
|
||||
.done
|
||||
.recv()
|
||||
.expect("Horizon renderer: the submit thread stopped");
|
||||
l.landed = Some(submission);
|
||||
}
|
||||
self.pending.store(false, Ordering::Release);
|
||||
}
|
||||
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
|
||||
/// made; `None` if it was taken already.
|
||||
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
|
||||
self.wait();
|
||||
self.lock().as_mut().and_then(|l| l.landed.take())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Queue {
|
||||
type Target = wgpu::Queue;
|
||||
fn deref(&self) -> &wgpu::Queue {
|
||||
self.wait();
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Queue {
|
||||
fn drop(&mut self) {
|
||||
self.wait();
|
||||
if let Some(thread) = &mut self.thread {
|
||||
thread.jobs.take();
|
||||
if let Some(handle) = thread.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
fn buffer(device: &wgpu::Device, usage: wgpu::BufferUsages) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 4,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_after_a_frame_on_the_thread_land_after_it() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let queue = Queue::new(queue, true);
|
||||
assert!(queue.threaded());
|
||||
use wgpu::BufferUsages as U;
|
||||
let source = buffer(&device, U::COPY_SRC | U::COPY_DST);
|
||||
let copy = buffer(&device, U::COPY_SRC | U::COPY_DST);
|
||||
let read = buffer(&device, U::MAP_READ | U::COPY_DST);
|
||||
queue.write_buffer(&source, 0, &[1, 2, 3, 4]);
|
||||
for frame in 0..3u8 {
|
||||
// The frame copies `source`; the next frame's write must not reach that copy.
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(&source, 0, ©, 0, 4);
|
||||
let job = Job {
|
||||
encoder,
|
||||
surface: None,
|
||||
after: None,
|
||||
};
|
||||
assert!(queue.submit_later(job).is_ok());
|
||||
queue.write_buffer(&source, 0, &[frame + 10; 4]);
|
||||
assert!(queue.landed().is_some(), "frame {frame} submitted");
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(©, 0, &read, 0, 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let expected = if frame == 0 {
|
||||
[1, 2, 3, 4]
|
||||
} else {
|
||||
[frame + 9; 4]
|
||||
};
|
||||
assert_eq!(*read.slice(..).get_mapped_range().unwrap(), expected);
|
||||
read.unmap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +246,61 @@ impl FarDetail {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the section mesher does with leaf faces inside a canopy. Terrain
|
||||
/// geometry only: entities, particles and block entities are untouched.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LeafCullingMode {
|
||||
/// Every leaf face, as vanilla's fancy leaves draw them.
|
||||
#[default]
|
||||
Off,
|
||||
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
|
||||
/// in the opaque layer, so their cutouts no longer show the layers behind.
|
||||
Solid,
|
||||
/// Like `Solid`, but a leaf with one open side draws opaque too: the
|
||||
/// exposed shell thins by a layer and the canopy looks chunkier.
|
||||
SolidAggressive,
|
||||
/// Drops the faces two leaves share. Faces toward air, cavities, partial
|
||||
/// models, unknown neighbours and section boundaries stay.
|
||||
Hollow,
|
||||
}
|
||||
impl LeafCullingMode {
|
||||
pub const ALL: [LeafCullingMode; 4] = [
|
||||
LeafCullingMode::Off,
|
||||
LeafCullingMode::Solid,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
LeafCullingMode::Hollow,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
LeafCullingMode::Off => "off",
|
||||
LeafCullingMode::Solid => "solid",
|
||||
LeafCullingMode::SolidAggressive => "solid_aggressive",
|
||||
LeafCullingMode::Hollow => "hollow",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
LeafCullingMode::Off => "Off",
|
||||
LeafCullingMode::Solid => "Solid",
|
||||
LeafCullingMode::SolidAggressive => "Solid Aggressive",
|
||||
LeafCullingMode::Hollow => "Hollow",
|
||||
}
|
||||
}
|
||||
/// Whether this mode draws interior leaf faces in the solid layer.
|
||||
pub fn is_interior(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
LeafCullingMode::Solid | LeafCullingMode::SolidAggressive
|
||||
)
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|mode| mode.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
|
||||
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -361,6 +416,46 @@ impl Graphics {
|
||||
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
|
||||
lod_distance_capped(self.lod_distance, info)
|
||||
}
|
||||
/// Whether the selected packs leave vanilla's block models in place.
|
||||
/// Horizon's occlusion assumptions (entity and leaf culling) hold only
|
||||
/// for those: a pack can replace an opaque cube's model or a leaf's.
|
||||
pub fn vanilla_models(&self, resource_packs: &[String]) -> bool {
|
||||
resource_packs.iter().all(|id| id == "vanilla")
|
||||
}
|
||||
/// The same check including packs overlaid by the connected server.
|
||||
/// Their ids are namespaced as `server/...`, so an active server pack
|
||||
/// disables model-dependent occlusion assumptions.
|
||||
pub fn vanilla_models_with_server_packs<'a>(
|
||||
&self,
|
||||
resource_packs: impl IntoIterator<Item = &'a str>,
|
||||
server_packs: impl IntoIterator<Item = &'a str>,
|
||||
) -> bool {
|
||||
resource_packs
|
||||
.into_iter()
|
||||
.chain(server_packs)
|
||||
.all(|id| id == "vanilla")
|
||||
}
|
||||
/// The leaf culling the mesher may apply. A pack that can replace leaf
|
||||
/// models falls back to `Off`, which draws exactly vanilla's leaves.
|
||||
pub fn leaf_culling_effective(&self, resource_packs: &[String]) -> LeafCullingMode {
|
||||
if self.vanilla_models(resource_packs) {
|
||||
self.leaf_culling
|
||||
} else {
|
||||
LeafCullingMode::Off
|
||||
}
|
||||
}
|
||||
/// The effective mode when local and server resource packs are active.
|
||||
pub fn leaf_culling_effective_with_server_packs<'a>(
|
||||
&self,
|
||||
resource_packs: impl IntoIterator<Item = &'a str>,
|
||||
server_packs: impl IntoIterator<Item = &'a str>,
|
||||
) -> LeafCullingMode {
|
||||
if self.vanilla_models_with_server_packs(resource_packs, server_packs) {
|
||||
self.leaf_culling
|
||||
} else {
|
||||
LeafCullingMode::Off
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Graphics {
|
||||
@@ -373,6 +468,10 @@ pub struct Graphics {
|
||||
pub cave_culling: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain.
|
||||
pub entity_culling: bool,
|
||||
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
|
||||
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
|
||||
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
pub chunk_updates: u32,
|
||||
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
|
||||
@@ -446,6 +545,7 @@ impl Default for Graphics {
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
entity_culling: false,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
texture_budget_mib: 0,
|
||||
@@ -515,6 +615,7 @@ impl Graphics {
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
"textureBudgetMiB" => {
|
||||
@@ -580,12 +681,13 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
bool_text(self.entity_culling),
|
||||
self.leaf_culling.name(),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
self.texture_budget_mib,
|
||||
@@ -742,6 +844,7 @@ mod tests {
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
entity_culling: true,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
texture_budget_mib: 1536,
|
||||
@@ -801,11 +904,96 @@ mod tests {
|
||||
Graphics::default()
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
|
||||
Graphics::parse(
|
||||
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
|
||||
),
|
||||
Graphics::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_round_trips_its_stable_names() {
|
||||
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
|
||||
for mode in LeafCullingMode::ALL {
|
||||
let g = Graphics {
|
||||
leaf_culling: mode,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g, "{mode:?}");
|
||||
assert_eq!(
|
||||
Graphics::parse(&format!("leafCulling:{}\n", mode.name())).leaf_culling,
|
||||
mode
|
||||
);
|
||||
}
|
||||
for text in [
|
||||
"leafCulling:other\n",
|
||||
"leafCulling:\n",
|
||||
"leafCulling:SOLID\n",
|
||||
] {
|
||||
assert_eq!(
|
||||
Graphics::parse(text).leaf_culling,
|
||||
LeafCullingMode::Off,
|
||||
"{text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_keep_the_leaf_culling_choice() {
|
||||
for mode in LeafCullingMode::ALL {
|
||||
for preset in Preset::ALL {
|
||||
let mut s = crate::settings::Settings::default();
|
||||
s.horizon.leaf_culling = mode;
|
||||
apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.leaf_culling, mode, "{preset:?}");
|
||||
assert_eq!(Graphics::parse(&s.horizon.serialize()), s.horizon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_packs_turn_leaf_culling_off() {
|
||||
let g = Graphics {
|
||||
leaf_culling: LeafCullingMode::Hollow,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert!(g.vanilla_models(&["vanilla".into()]));
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective(&["vanilla".into()]),
|
||||
LeafCullingMode::Hollow
|
||||
);
|
||||
assert!(!g.vanilla_models(&["vanilla".into(), "My Pack".into()]));
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective(&["vanilla".into(), "My Pack".into()]),
|
||||
LeafCullingMode::Off
|
||||
);
|
||||
assert_eq!(g.leaf_culling_effective(&[]), LeafCullingMode::Hollow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_pack_apply_disables_leaf_culling_and_removal_restores_it() {
|
||||
let g = Graphics {
|
||||
leaf_culling: LeafCullingMode::Solid,
|
||||
..Graphics::default()
|
||||
};
|
||||
let selected = ["vanilla"];
|
||||
let applied_server_pack = ["server/0123456789abcdef/model-pack.zip"];
|
||||
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective_with_server_packs(selected, applied_server_pack),
|
||||
LeafCullingMode::Off,
|
||||
"an applied server pack can replace block models"
|
||||
);
|
||||
assert!(!g.vanilla_models_with_server_packs(selected, applied_server_pack));
|
||||
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective_with_server_packs(selected, []),
|
||||
LeafCullingMode::Solid,
|
||||
"removing the server pack restores the selected local mode"
|
||||
);
|
||||
assert!(g.vanilla_models_with_server_packs(selected, []));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_gpus_other_than_apple_cap_the_lod_distance() {
|
||||
let adapter = |name: &str, device_type| wgpu::AdapterInfo {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
|
||||
graphics::{
|
||||
FarDetail, GraphicsApi, LeafCullingMode, Overlay, Pacing, PresentMode, Preset, Upscaling,
|
||||
},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
@@ -27,6 +29,7 @@ pub enum H {
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
EntityCulling,
|
||||
LeafCulling,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
Sky,
|
||||
@@ -187,6 +190,12 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Medium,
|
||||
"How far away mobs and items are drawn, as a share of normal.",
|
||||
),
|
||||
h(
|
||||
H::ChunkUpdates,
|
||||
Low,
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
),
|
||||
Row::Header("Culling"),
|
||||
h(
|
||||
H::CaveCulling,
|
||||
Saves,
|
||||
@@ -198,9 +207,9 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
),
|
||||
h(
|
||||
H::ChunkUpdates,
|
||||
Low,
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
H::LeafCulling,
|
||||
Saves,
|
||||
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
|
||||
),
|
||||
Row::Header("Quality"),
|
||||
v(
|
||||
@@ -446,6 +455,16 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
|
||||
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
|
||||
LeafCullingMode::Solid => {
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers."
|
||||
}
|
||||
LeafCullingMode::SolidAggressive => {
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges."
|
||||
}
|
||||
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
|
||||
},
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
@@ -516,6 +535,7 @@ impl H {
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
|
||||
@@ -649,6 +669,9 @@ impl H {
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
H::LeafCulling => {
|
||||
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
|
||||
}
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
H::Weather => g.weather = !g.weather,
|
||||
@@ -1825,6 +1848,82 @@ mod tests {
|
||||
assert!(shown(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_cycles_modes_in_the_advanced_culling_group() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::LeafCulling);
|
||||
assert_eq!(item.switch(&s), None);
|
||||
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
|
||||
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
|
||||
assert_eq!(
|
||||
crate::graphics::Graphics::parse(&s.horizon.serialize()).leaf_culling,
|
||||
s.horizon.leaf_culling
|
||||
);
|
||||
}
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
|
||||
|
||||
// The three culling settings share one group, below Rendering's chunk
|
||||
// updates and above Quality.
|
||||
let rows = rows(Level::Advanced);
|
||||
let culling = rows
|
||||
.iter()
|
||||
.position(|row| matches!(row, Row::Header("Culling")))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
rows.get(culling + 1),
|
||||
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::CaveCulling)
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 2),
|
||||
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::EntityCulling)
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 3),
|
||||
Some(Row::Setting(setting)) if setting.item == item
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 4),
|
||||
Some(Row::Header("Quality"))
|
||||
));
|
||||
|
||||
// The description names what the picked mode does.
|
||||
for mode in LeafCullingMode::ALL {
|
||||
s.horizon.leaf_culling = mode;
|
||||
let text = description(item, &s, "fallback");
|
||||
assert_ne!(text, "fallback", "{mode:?}");
|
||||
assert!(text.len() < 80, "{mode:?}: {text}");
|
||||
}
|
||||
|
||||
// A click cycles it and leaves the preset, as the other quality
|
||||
// settings do.
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let control = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
s.horizon.leaf_culling = LeafCullingMode::Off;
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
(control.x + 1.0, control.y + 1.0),
|
||||
size,
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -83,6 +83,8 @@ pub struct HudAtlas {
|
||||
/// A resource pack's own `core/text` shader for the GUI's text, translated;
|
||||
/// none draws the text natively.
|
||||
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
pub random_textures: horizon_optifine::random_textures::RandomTextures,
|
||||
white: Rect,
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
@@ -479,6 +481,7 @@ impl HudAtlas {
|
||||
glint_armor: (vec![0; 4], 1, 1),
|
||||
white,
|
||||
text_shader: None,
|
||||
random_textures: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
@@ -567,15 +570,41 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
// OptiFine random entity variants, under the names their scan gives them (those
|
||||
// outside `textures/` are `<ns>:optifine/random/...`).
|
||||
let random_textures = if folders.contains(&"entity") {
|
||||
horizon_optifine::random_textures::RandomTextures::scan(root)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut named: HashMap<PathBuf, String> = HashMap::new();
|
||||
for (file, name) in random_textures.files() {
|
||||
named.insert(root.join(file), name.to_owned());
|
||||
}
|
||||
if !named.is_empty() {
|
||||
// Those beside their texture are walked already.
|
||||
let walked: HashSet<&PathBuf> = files.iter().collect();
|
||||
let mut extra: Vec<PathBuf> = named
|
||||
.keys()
|
||||
.filter(|p| !walked.contains(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
extra.sort();
|
||||
files.extend(extra);
|
||||
}
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
let name = if let Some(name) = named.get(path) {
|
||||
name.clone()
|
||||
} else {
|
||||
match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
}
|
||||
};
|
||||
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
|
||||
@@ -777,6 +806,7 @@ impl HudAtlas {
|
||||
glint,
|
||||
glint_armor,
|
||||
text_shader: None,
|
||||
random_textures,
|
||||
font,
|
||||
white,
|
||||
})
|
||||
|
||||
@@ -18,9 +18,11 @@ pub mod camera;
|
||||
pub mod chat;
|
||||
pub mod chunk;
|
||||
pub mod chunk_map;
|
||||
pub mod cit;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
pub mod command_block;
|
||||
pub mod connected;
|
||||
pub mod containers;
|
||||
pub mod crash;
|
||||
pub mod datapack;
|
||||
|
||||
@@ -47,6 +47,9 @@ fn meshing_threads(cores: usize) -> usize {
|
||||
/// terrain starts one sample job at most this often and no detail: it would
|
||||
/// otherwise take the cores the near chunks and the frame need.
|
||||
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
/// How long a tile whose sampling failed (`LodSampler::grid` gave no
|
||||
/// columns: a server refused or lost it) waits before it is asked again.
|
||||
const RETRY: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
|
||||
/// around (a level-22 tile is 67 million blocks wide: the whole world).
|
||||
pub(crate) const MAX_LEVEL: u8 = 22;
|
||||
@@ -922,6 +925,8 @@ pub struct Lod {
|
||||
near_loading: bool,
|
||||
/// When `request_samples` last started a job while held.
|
||||
held_sample_at: std::time::Instant,
|
||||
/// Tiles whose sampling failed, and when they may be asked again.
|
||||
retry: FxHashMap<TileKey, std::time::Instant>,
|
||||
/// Each tile's samples, shared with mesh jobs in flight.
|
||||
ready: FxHashMap<TileKey, Arc<Vec<Column>>>,
|
||||
/// Fully covered subtrees, by tile.
|
||||
@@ -1258,6 +1263,7 @@ impl Lod {
|
||||
sample_jobs: SAMPLE_JOBS.max(sampling_threads(cores) + 1),
|
||||
near_loading: false,
|
||||
held_sample_at: std::time::Instant::now(),
|
||||
retry: FxHashMap::default(),
|
||||
ready: FxHashMap::default(),
|
||||
complete: FxHashMap::default(),
|
||||
active: FxHashSet::default(),
|
||||
@@ -1689,7 +1695,16 @@ impl Lod {
|
||||
}
|
||||
fn finish_sample(&mut self, key: TileKey, samples: Vec<Column>) {
|
||||
self.pending.remove(&key);
|
||||
if self.plan.nodes.contains(&key) && samples.len() == CELLS * CELLS {
|
||||
if samples.len() != CELLS * CELLS {
|
||||
let now = std::time::Instant::now();
|
||||
if self.retry.len() > 4096 {
|
||||
self.retry.retain(|_, at| *at > now);
|
||||
}
|
||||
self.retry.insert(key, now + RETRY);
|
||||
return;
|
||||
}
|
||||
self.retry.remove(&key);
|
||||
if self.plan.nodes.contains(&key) {
|
||||
let mut samples = samples;
|
||||
if let (Some(old), Some(mask)) = (self.ready.get(&key), self.detail_masks.get(&key)) {
|
||||
for i in 0..256 {
|
||||
@@ -2241,8 +2256,16 @@ impl Lod {
|
||||
if free == 0 {
|
||||
return;
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
if self.held() {
|
||||
self.held_sample_at = std::time::Instant::now();
|
||||
self.held_sample_at = now;
|
||||
}
|
||||
// A tile whose sampling failed waits out `RETRY`; once it has, the
|
||||
// queue is rebuilt so the tile is asked again.
|
||||
let retrying = self.retry.len();
|
||||
self.retry.retain(|_, at| *at > now);
|
||||
if self.retry.len() != retrying {
|
||||
self.wanted_stale = true;
|
||||
}
|
||||
// The queue is rebuilt when the view moves a chunk, not scanned and
|
||||
// sorted on every frame.
|
||||
@@ -2268,6 +2291,7 @@ impl Lod {
|
||||
|| self.ready.contains_key(&key)
|
||||
|| (self.plan.split.contains(&key) && wanted.class != BACKDROP)
|
||||
|| !self.plan.nodes.contains(&key)
|
||||
|| self.retry.contains_key(&key)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -2282,8 +2306,19 @@ impl Lod {
|
||||
}
|
||||
fn rebuild_wanted(&mut self, camera: [i32; 2]) {
|
||||
self.wanted.clear();
|
||||
self.wanted
|
||||
.extend(wanted_tiles(&self.plan, &self.ready, camera).map(std::cmp::Reverse));
|
||||
// A failed tile waits out its retry delay (`retry`) before it is asked again.
|
||||
let retry = &self.retry;
|
||||
self.wanted.extend(
|
||||
wanted_tiles(&self.plan, &self.ready, camera)
|
||||
.filter(|w| {
|
||||
!retry.contains_key(&TileKey {
|
||||
x: w.x,
|
||||
z: w.z,
|
||||
level: w.level,
|
||||
})
|
||||
})
|
||||
.map(std::cmp::Reverse),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
|
||||
//! follow `FluidRenderer` (see `fluid`).
|
||||
mod fluid;
|
||||
mod leaf_culling;
|
||||
mod lighting;
|
||||
mod sides;
|
||||
use crate::{
|
||||
@@ -11,6 +12,7 @@ use crate::{
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
use horizon_core::{
|
||||
@@ -470,6 +472,9 @@ struct Info {
|
||||
overlay: bool,
|
||||
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
|
||||
leaves: bool,
|
||||
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
|
||||
/// and a full collision shape. The only block leaf culling treats as solid.
|
||||
opaque_cube: bool,
|
||||
blocks_flow: bool,
|
||||
ice: bool,
|
||||
sturdy: u8,
|
||||
@@ -647,6 +652,12 @@ impl MeshTables {
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
leaves: s.has_class("LeavesBlock"),
|
||||
opaque_cube: leaf_culling::opaque_cube(
|
||||
s.render_shape,
|
||||
s.can_occlude,
|
||||
s.solid_render,
|
||||
s.full_collision,
|
||||
),
|
||||
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
|
||||
ice: s.has_class("IceBlock"),
|
||||
sturdy: s.sturdy_full,
|
||||
@@ -686,6 +697,14 @@ impl MeshTables {
|
||||
tint_sources: i.tint_count,
|
||||
})
|
||||
}
|
||||
/// Whether `state` is a full opaque cube: the only block leaf culling
|
||||
/// treats as a solid neighbour. Glass, fluids, slabs, stairs and models
|
||||
/// that only partly fill their block are not.
|
||||
pub fn opaque_cube(&self, state: BlockStateId) -> bool {
|
||||
self.info
|
||||
.get(state.0 as usize)
|
||||
.is_some_and(|i| i.opaque_cube)
|
||||
}
|
||||
/// `BlockTintSource.color(state)`: block-model colour without a level.
|
||||
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
|
||||
/// Untinted faces, blank sources and invalid tint indices return `None`.
|
||||
@@ -860,14 +879,18 @@ impl Padded {
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 88 KiB per worker.
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
leaf: leaf_culling::LeafMask,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
@@ -1079,6 +1102,8 @@ pub struct Mesher<'a> {
|
||||
pub smooth_lighting: bool,
|
||||
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
|
||||
pub cutout_leaves: bool,
|
||||
/// What the worker does with leaf faces inside a canopy.
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
}
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
@@ -1108,6 +1133,11 @@ impl Mesher<'_> {
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
.leaf
|
||||
.prepare(self.leaf_culling, pad, &self.tables.info, region, base_y);
|
||||
}
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
self.biomes,
|
||||
@@ -1172,13 +1202,21 @@ impl Mesher<'_> {
|
||||
None => [x as f32, y as f32, z as f32],
|
||||
};
|
||||
let mut visible = [false; 6];
|
||||
let interior_leaf = state.leaves
|
||||
&& self.leaf_culling.is_interior()
|
||||
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
|
||||
for d in Direction::ALL {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
let other = &info[neighbor as usize];
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
|
||||
&& state.leaves
|
||||
&& other.leaves
|
||||
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
|
||||
visible[d as usize] = !opaque_leaves
|
||||
&& !hollow_leaf
|
||||
&& self
|
||||
.visibility
|
||||
.should_render_face(
|
||||
@@ -1203,6 +1241,7 @@ impl Mesher<'_> {
|
||||
face,
|
||||
ao,
|
||||
state,
|
||||
interior_leaf,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1241,6 +1280,7 @@ impl Mesher<'_> {
|
||||
face: &BakedFace,
|
||||
ao: bool,
|
||||
state: &Info,
|
||||
interior_leaf: bool,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
// A quad's own light (a model's glowing part) may raise the tier; whether it
|
||||
@@ -1275,6 +1315,17 @@ impl Mesher<'_> {
|
||||
} else {
|
||||
&mut out.solid
|
||||
};
|
||||
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
|
||||
// leaf-culling mode proved internal draw in the solid layer: no alpha
|
||||
// cutout. All four vertices of the quad share the layer.
|
||||
let layer = if face.opaque && !face.translucent
|
||||
|| !self.cutout_leaves && state.leaves
|
||||
|| interior_leaf
|
||||
{
|
||||
face.layer as u16 | OPAQUE_LAYER
|
||||
} else {
|
||||
face.layer as u16
|
||||
};
|
||||
for i in 0..4 {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
@@ -1285,13 +1336,7 @@ impl Mesher<'_> {
|
||||
target.push(TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
q.uv[i],
|
||||
// Opaque leaves (forceOpaque) and quads over opaque texels draw in the
|
||||
// solid layer: no alpha cutout (a dirt path's side skips its clear top row).
|
||||
if face.opaque && !face.translucent || !self.cutout_leaves && state.leaves {
|
||||
face.layer as u16 | OPAQUE_LAYER
|
||||
} else {
|
||||
face.layer as u16
|
||||
},
|
||||
layer,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
//! Leaf culling: decides which leaf faces a section may draw solid or drop.
|
||||
//!
|
||||
//! The mask is built on the mesh worker from the same padded block snapshot the
|
||||
//! rest of the mesher reads, so a decision never needs a chunk lookup on the
|
||||
//! render thread. It classifies each padded cell as a leaf, a full opaque cube,
|
||||
//! open (air, fluids, partial models, anything else) or unknown (a missing
|
||||
//! chunk, a Y outside the column, or the mask's outer shell). Only leaves can
|
||||
//! hide or solidify anything; unknown and open cells always expose a face.
|
||||
//!
|
||||
//! `Solid` performs an immediate 6-neighbour occlusion check, solidifying fully
|
||||
//! enclosed leaves into the opaque layer without cutout testing. `Solid
|
||||
//! Aggressive` runs a multi-source breadth-first search from leaves touching
|
||||
//! openings, solidifying leaves with a single open face to thin the canopy shell
|
||||
//! by one layer. `Hollow` directly drops faces shared between two leaves within
|
||||
//! the section, preserving boundaries and cavity faces.
|
||||
|
||||
use super::{B, Info, P, Padded, pi};
|
||||
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
|
||||
|
||||
/// A cell of the padded snapshot, as far as leaf culling cares.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Cell {
|
||||
/// Air, a fluid, a partial model, glass or any other non-occluding block.
|
||||
Open,
|
||||
/// `LeavesBlock`.
|
||||
Leaf,
|
||||
/// A full opaque cube (see [`opaque_cube`]).
|
||||
Opaque,
|
||||
/// Missing chunk, missing section or the mask's outer shell.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Whether a state is a full opaque cube: `RenderShape::Model`, `can_occlude`,
|
||||
/// `solid_render` and a full collision shape. Glass, fluids, slabs, stairs,
|
||||
/// chests and models that only partly fill their block are not.
|
||||
pub(super) fn opaque_cube(
|
||||
shape: RenderShape,
|
||||
can_occlude: bool,
|
||||
solid_render: bool,
|
||||
full_collision: bool,
|
||||
) -> bool {
|
||||
shape == RenderShape::Model && can_occlude && solid_render && full_collision
|
||||
}
|
||||
|
||||
const UNVISITED: u8 = u8::MAX;
|
||||
|
||||
const STRIDE_X: isize = 1;
|
||||
const STRIDE_Z: isize = P as isize;
|
||||
const STRIDE_Y: isize = (P * P) as isize;
|
||||
|
||||
const ADJACENT_STRIDES: [isize; 6] = [
|
||||
STRIDE_X, -STRIDE_X, STRIDE_Z, -STRIDE_Z, STRIDE_Y, -STRIDE_Y,
|
||||
];
|
||||
|
||||
/// Per-worker leaf mask and BFS scratch. The vectors keep their capacity
|
||||
/// between sections and modes; `Off` does no work at all.
|
||||
#[derive(Default)]
|
||||
pub(super) struct LeafMask {
|
||||
cells: Vec<Cell>,
|
||||
depth: Vec<u8>,
|
||||
queue: Vec<u16>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl LeafMask {
|
||||
/// Classify the padded snapshot and, for the interior modes, measure how
|
||||
/// deep each leaf sits below the canopy surface.
|
||||
pub(super) fn prepare(
|
||||
&mut self,
|
||||
mode: LeafCullingMode,
|
||||
pad: &Padded,
|
||||
info: &[Info],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
base_y: i32,
|
||||
) {
|
||||
self.active = mode != LeafCullingMode::Off;
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
// Most sections hold no leaves at all; do not classify 8,000 cells to
|
||||
// learn that. The centre is the only part this mesh can change.
|
||||
let mut leaves = false;
|
||||
'scan: for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
if info
|
||||
.get(pad.blocks[pi(x, y, z)] as usize)
|
||||
.is_some_and(|state| state.leaves)
|
||||
{
|
||||
leaves = true;
|
||||
break 'scan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !leaves {
|
||||
self.active = false;
|
||||
return;
|
||||
}
|
||||
if self.cells.len() != P * P * P {
|
||||
self.cells.resize(P * P * P, Cell::Open);
|
||||
}
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let i = pi(x, y, z);
|
||||
self.cells[i] = if !known(region, base_y, x, y, z) {
|
||||
Cell::Unknown
|
||||
} else {
|
||||
match info.get(pad.blocks[i] as usize) {
|
||||
None => Cell::Unknown,
|
||||
Some(state) if state.leaves => Cell::Leaf,
|
||||
Some(state) if state.opaque_cube => Cell::Opaque,
|
||||
Some(_) => Cell::Open,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Solid is a purely local six-neighbour test. Reserve the breadth-first
|
||||
// search for Solid Aggressive, whose one-open-side rule needs to know
|
||||
// whether a leaf is connected to the canopy surface through other
|
||||
// leaves.
|
||||
if mode == LeafCullingMode::SolidAggressive {
|
||||
self.depths();
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-source BFS from leaves that touch an opening. Opaque neighbours do
|
||||
/// not expose a leaf: a leaf fully enclosed by stone is interior too.
|
||||
fn depths(&mut self) {
|
||||
if self.depth.len() != self.cells.len() {
|
||||
self.depth.resize(self.cells.len(), UNVISITED);
|
||||
} else {
|
||||
self.depth.fill(UNVISITED);
|
||||
}
|
||||
self.queue.clear();
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let i = pi(x, y, z);
|
||||
if self.cells[i] != Cell::Leaf {
|
||||
continue;
|
||||
}
|
||||
let touches_open = ADJACENT_STRIDES.iter().any(|&stride| {
|
||||
let j = (i as isize + stride) as usize;
|
||||
matches!(self.cells[j], Cell::Open | Cell::Unknown)
|
||||
});
|
||||
if touches_open {
|
||||
self.depth[i] = 0;
|
||||
self.queue.push(i as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut head = 0;
|
||||
while head < self.queue.len() {
|
||||
let i = self.queue[head] as usize;
|
||||
head += 1;
|
||||
let next_depth = self.depth[i].saturating_add(1);
|
||||
for stride in ADJACENT_STRIDES {
|
||||
let j = (i as isize + stride) as usize;
|
||||
if self.cells[j] == Cell::Leaf && self.depth[j] == UNVISITED {
|
||||
self.depth[j] = next_depth;
|
||||
self.queue.push(j as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the leaf at `(x, y, z)` may draw in the opaque layer.
|
||||
///
|
||||
/// `Solid` wants a leaf with no open neighbour, which is exactly depth 1 or
|
||||
/// more. `Solid Aggressive` also takes the next layer of the shell: a leaf
|
||||
/// with a single open side joins the opaque core, so the cutout shell is
|
||||
/// one layer thinner. Unknown neighbours never qualify, however many sides
|
||||
/// they leave open. A leaf the search never reached sits inside a closed
|
||||
/// volume: interior for both.
|
||||
pub(super) fn interior(&self, mode: LeafCullingMode, x: i32, y: i32, z: i32) -> bool {
|
||||
if !self.active {
|
||||
return false;
|
||||
}
|
||||
let aggressive = match mode {
|
||||
LeafCullingMode::Solid => false,
|
||||
LeafCullingMode::SolidAggressive => true,
|
||||
_ => return false,
|
||||
};
|
||||
// A section boundary is not proof of a closed volume: the neighbouring
|
||||
// section may not be drawn yet, and its data is not ours to assume.
|
||||
if !(1..15).contains(&x) || !(1..15).contains(&y) || !(1..15).contains(&z) {
|
||||
return false;
|
||||
}
|
||||
let i = pi(x, y, z);
|
||||
if self.cells.get(i) != Some(&Cell::Leaf) {
|
||||
return false;
|
||||
}
|
||||
if !aggressive {
|
||||
return ADJACENT_STRIDES.iter().all(|&stride| {
|
||||
let j = (i as isize + stride) as usize;
|
||||
matches!(self.cells[j], Cell::Leaf | Cell::Opaque)
|
||||
});
|
||||
}
|
||||
let depth = self.depth[i];
|
||||
if depth == UNVISITED || depth >= 1 {
|
||||
return true;
|
||||
}
|
||||
let mut open = 0;
|
||||
for stride in ADJACENT_STRIDES {
|
||||
let j = (i as isize + stride) as usize;
|
||||
match self.cells[j] {
|
||||
Cell::Unknown => return false,
|
||||
Cell::Open => {
|
||||
open += 1;
|
||||
if open > 1 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Cell::Leaf | Cell::Opaque => {}
|
||||
}
|
||||
}
|
||||
open == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells this section's mesh owns. A face on this boundary belongs to the
|
||||
/// neighbouring section as much as to this one; leaf culling leaves it alone.
|
||||
#[inline]
|
||||
pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
|
||||
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
|
||||
}
|
||||
|
||||
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
|
||||
/// does not count: `Padded::fill` copies the centre's edge column into it, which
|
||||
/// would make a canopy look enclosed. A Y outside the column counts as unknown
|
||||
/// for the same reason. The mask's outer shell is unknown too, so nothing is
|
||||
/// inferred past the data the worker snapshotted.
|
||||
fn known(region: &[[Option<&Chunk>; 3]; 3], base_y: i32, x: i32, y: i32, z: i32) -> bool {
|
||||
if x <= -B || x >= 16 + B - 1 || y <= -B || y >= 16 + B - 1 || z <= -B || z >= 16 + B - 1 {
|
||||
return false;
|
||||
}
|
||||
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
|
||||
chunk.is_some_and(|chunk| chunk.section_index(base_y + y).is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl LeafMask {
|
||||
/// Whether `Hollow` drops the face of the leaf at `(x, y, z)` toward `d`.
|
||||
/// Only a known leaf on the other side counts: open cells, partial models,
|
||||
/// missing neighbours and the mask's edge stay visible.
|
||||
pub(super) fn hides_face(
|
||||
&self,
|
||||
mode: LeafCullingMode,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
d: horizon_core::geometry::Direction,
|
||||
) -> bool {
|
||||
if !self.active || mode != LeafCullingMode::Hollow {
|
||||
return false;
|
||||
}
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
|
||||
if !in_section(x, y, z) || !in_section(nx, ny, nz) {
|
||||
return false;
|
||||
}
|
||||
let i = pi(x, y, z);
|
||||
let ni = pi(nx, ny, nz);
|
||||
self.cells.get(i) == Some(&Cell::Leaf) && self.cells.get(ni) == Some(&Cell::Leaf)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn cell(&self, x: i32, y: i32, z: i32) -> Cell {
|
||||
checked(x, y, z)
|
||||
.and_then(|i| self.cells.get(i))
|
||||
.copied()
|
||||
.unwrap_or(Cell::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[inline]
|
||||
fn checked(x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
if (-B..16 + B).contains(&x) && (-B..16 + B).contains(&y) && (-B..16 + B).contains(&z) {
|
||||
Some(pi(x, y, z))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[inline]
|
||||
fn unpi(i: usize) -> (i32, i32, i32) {
|
||||
let x = (i % P) as i32 - B;
|
||||
let z = (i / P % P) as i32 - B;
|
||||
let y = (i / (P * P)) as i32 - B;
|
||||
(x, y, z)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{blocks::RenderShape, graphics::LeafCullingMode};
|
||||
use horizon_core::geometry::Direction;
|
||||
use horizon_world::fluid::FluidState;
|
||||
use std::time::Instant;
|
||||
|
||||
fn info(leaves: bool, opaque: bool) -> Info {
|
||||
Info {
|
||||
solid: opaque,
|
||||
full: opaque,
|
||||
permeable: false,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: true,
|
||||
block_entity: false,
|
||||
tints: [super::super::Tint::None; 2],
|
||||
tint_count: 0,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: leaves,
|
||||
leaves,
|
||||
opaque_cube: opaque,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
face: [0; 6],
|
||||
cover: [0.0; 6],
|
||||
}
|
||||
}
|
||||
|
||||
/// State 0 is air, 1 leaves, 2 a full opaque cube, 3 a partial model.
|
||||
fn states() -> Vec<Info> {
|
||||
vec![
|
||||
info(false, false),
|
||||
info(true, false),
|
||||
info(false, true),
|
||||
info(false, false),
|
||||
]
|
||||
}
|
||||
|
||||
fn padded(fill: impl Fn(i32, i32, i32) -> u32) -> Padded {
|
||||
Padded {
|
||||
blocks: (0..P * P * P)
|
||||
.map(|i| {
|
||||
let (x, y, z) = unpi(i);
|
||||
fill(x, y, z)
|
||||
})
|
||||
.collect(),
|
||||
light: vec![0; P * P * P],
|
||||
}
|
||||
}
|
||||
|
||||
fn cube(pad: &mut Padded, min: i32, max: i32, state: u32) {
|
||||
for y in min..=max {
|
||||
for z in min..=max {
|
||||
for x in min..=max {
|
||||
pad.blocks[pi(x, y, z)] = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn loaded() -> Chunk {
|
||||
Chunk::empty(0, 0)
|
||||
}
|
||||
|
||||
fn region(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
|
||||
[[Some(chunk); 3]; 3]
|
||||
}
|
||||
|
||||
fn built(mode: LeafCullingMode, pad: &Padded, states: &[Info], chunk: &Chunk) -> LeafMask {
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(mode, pad, states, ®ion(chunk), 0);
|
||||
mask
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_full_model_cube_is_opaque() {
|
||||
let combinations = [
|
||||
(RenderShape::Model, true, true, true),
|
||||
(RenderShape::Model, true, true, false),
|
||||
(RenderShape::Model, true, false, true),
|
||||
(RenderShape::Model, false, true, true),
|
||||
(RenderShape::Model, false, false, false),
|
||||
(RenderShape::Invisible, true, true, true),
|
||||
(RenderShape::Animated, true, true, true),
|
||||
];
|
||||
let (shape, can_occlude, solid_render, full_collision) = combinations[0];
|
||||
assert!(opaque_cube(
|
||||
shape,
|
||||
can_occlude,
|
||||
solid_render,
|
||||
full_collision
|
||||
));
|
||||
for &(shape, can_occlude, solid_render, full_collision) in &combinations[1..] {
|
||||
assert!(
|
||||
!opaque_cube(shape, can_occlude, solid_render, full_collision),
|
||||
"{shape:?} {can_occlude} {solid_render} {full_collision}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_marks_the_cell_inside_a_three_cube() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
let chunk = loaded();
|
||||
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
|
||||
assert!(solid.interior(LeafCullingMode::Solid, 8, 8, 8));
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 8, 9, 8));
|
||||
// Aggressive also takes the six face-centre leaves, whose only open
|
||||
// side faces out of the cube. Corners and edges keep their cutout.
|
||||
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
|
||||
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 8));
|
||||
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 7, 8, 8));
|
||||
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 9));
|
||||
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 8));
|
||||
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggressive_takes_the_next_shell_layer() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 6, 10, 1);
|
||||
let chunk = loaded();
|
||||
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
|
||||
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
|
||||
let surface = |v: i32| v == 6 || v == 10;
|
||||
for [x, y, z] in [
|
||||
[6, 8, 8],
|
||||
[10, 8, 8],
|
||||
[6, 6, 8],
|
||||
[6, 6, 6],
|
||||
[7, 8, 8],
|
||||
[8, 8, 8],
|
||||
[7, 7, 7],
|
||||
[8, 8, 7],
|
||||
] {
|
||||
let expected = !surface(x) && !surface(y) && !surface(z);
|
||||
assert_eq!(
|
||||
solid.interior(LeafCullingMode::Solid, x, y, z),
|
||||
expected,
|
||||
"solid {x} {y} {z}"
|
||||
);
|
||||
let open = [x, y, z].iter().filter(|v| surface(**v)).count();
|
||||
let expected = open <= 1;
|
||||
assert_eq!(
|
||||
aggressive.interior(LeafCullingMode::SolidAggressive, x, y, z),
|
||||
expected,
|
||||
"aggressive {x} {y} {z}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hollow_hides_only_leaf_to_leaf_faces() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
// Glass (3, a partial model), water and a slab are open: their leaves
|
||||
// must keep the face.
|
||||
pad.blocks[pi(10, 8, 8)] = 3;
|
||||
let chunk = loaded();
|
||||
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
|
||||
for d in Direction::ALL {
|
||||
assert!(
|
||||
hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
|
||||
"{d:?}"
|
||||
);
|
||||
}
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 9, 8, 8, Direction::East));
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::West));
|
||||
// The other modes never hide a quad.
|
||||
for mode in [
|
||||
LeafCullingMode::Off,
|
||||
LeafCullingMode::Solid,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
] {
|
||||
let mask = built(mode, &pad, &states, &chunk);
|
||||
assert!(!mask.hides_face(mode, 8, 8, 8, Direction::Up));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cavity_keeps_its_faces_and_its_rim_visible() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 6, 10, 1);
|
||||
pad.blocks[pi(8, 8, 8)] = 0;
|
||||
let chunk = loaded();
|
||||
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::East));
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 7, Direction::South));
|
||||
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 6, 8, 8));
|
||||
// The corner away from the cavity is still two layers in.
|
||||
assert!(solid.interior(LeafCullingMode::Solid, 9, 9, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opaque_neighbour_does_not_expose_a_leaf() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
for (dx, dy, dz) in [
|
||||
(1, 0, 0),
|
||||
(-1, 0, 0),
|
||||
(0, 1, 0),
|
||||
(0, -1, 0),
|
||||
(0, 0, 1),
|
||||
(0, 0, -1),
|
||||
] {
|
||||
pad.blocks[pi(8 + dx, 8 + dy, 8 + dz)] = 2;
|
||||
}
|
||||
pad.blocks[pi(8, 8, 8)] = 1;
|
||||
let chunk = loaded();
|
||||
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
|
||||
let mask = built(mode, &pad, &states, &chunk);
|
||||
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
|
||||
}
|
||||
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
|
||||
for d in Direction::ALL {
|
||||
assert!(
|
||||
!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
|
||||
"{d:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leaf_inside_a_closed_volume_is_interior() {
|
||||
let states = states();
|
||||
// A leaf cube inside opaque rock: the search has no opening to start
|
||||
// from, so every leaf stays unvisited and counts as interior.
|
||||
let mut pad = padded(|_, _, _| 2);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
let chunk = loaded();
|
||||
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
|
||||
let mask = built(mode, &pad, &states, &chunk);
|
||||
if mode == LeafCullingMode::SolidAggressive {
|
||||
assert_eq!(mask.depth[pi(8, 8, 8)], UNVISITED);
|
||||
}
|
||||
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_chunks_and_sections_are_unknown() {
|
||||
let states = states();
|
||||
let pad = padded(|_, _, _| 1);
|
||||
let chunk = loaded();
|
||||
let mut around = [[Some(&chunk); 3]; 3];
|
||||
around[1][2] = None; // east chunk
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &around, 0);
|
||||
// The leaf at the edge keeps its face toward the missing chunk, even
|
||||
// though `Padded::fill` copied the centre's edge leaf there.
|
||||
assert!(!mask.hides_face(LeafCullingMode::Hollow, 15, 8, 8, Direction::East));
|
||||
// A leaf one block inside is still between two known leaves.
|
||||
assert!(mask.hides_face(LeafCullingMode::Hollow, 14, 8, 8, Direction::East));
|
||||
|
||||
// A section above the column is unknown too: the same leaf one
|
||||
// section up has no data, so it can never be interior.
|
||||
let short = Chunk::with_layout(0, 0, crate::chunk::ColumnLayout::new(0, 16).unwrap());
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Solid, &pad, &states, ®ion(&short), 16);
|
||||
assert!(!mask.interior(LeafCullingMode::Solid, 8, 8, 8));
|
||||
|
||||
// The outer shell has no data beyond it.
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Hollow, &pad, &states, ®ion(&chunk), 0);
|
||||
assert_eq!(mask.cell(-B, 0, 0), Cell::Unknown);
|
||||
assert_eq!(mask.cell(16 + B - 1, 0, 0), Cell::Unknown);
|
||||
assert_eq!(mask.cell(0, 0, 0), Cell::Leaf);
|
||||
|
||||
// A section boundary is never enclosed, even with every chunk loaded:
|
||||
// the neighbouring section may not have been meshed yet.
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Solid, &pad, &states, ®ion(&chunk), 0);
|
||||
assert!(!mask.interior(LeafCullingMode::Solid, 15, 8, 8));
|
||||
assert!(mask.interior(LeafCullingMode::Solid, 14, 8, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_does_no_work_and_hides_nothing() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
let chunk = loaded();
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Off, &pad, &states, ®ion(&chunk), 0);
|
||||
assert!(!mask.active);
|
||||
assert!(mask.cells.is_empty());
|
||||
assert!(!mask.interior(LeafCullingMode::Off, 8, 8, 8));
|
||||
assert!(!mask.hides_face(LeafCullingMode::Off, 8, 8, 8, Direction::Up));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reused_mask_reclassifies_every_cell() {
|
||||
let states = states();
|
||||
let chunk = loaded();
|
||||
let mut mask = LeafMask::default();
|
||||
let mut leaves = padded(|_, _, _| 0);
|
||||
cube(&mut leaves, 7, 9, 1);
|
||||
mask.prepare(
|
||||
LeafCullingMode::Hollow,
|
||||
&leaves,
|
||||
&states,
|
||||
®ion(&chunk),
|
||||
0,
|
||||
);
|
||||
assert_eq!(mask.cell(8, 8, 8), Cell::Leaf);
|
||||
let mut other = padded(|_, _, _| 0);
|
||||
cube(&mut other, 10, 12, 1);
|
||||
mask.prepare(LeafCullingMode::Hollow, &other, &states, ®ion(&chunk), 0);
|
||||
assert_eq!(mask.cell(8, 8, 8), Cell::Open);
|
||||
assert_eq!(mask.cell(11, 11, 11), Cell::Leaf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "microbenchmark; run with --ignored --nocapture"]
|
||||
fn mask_build_benchmark() {
|
||||
let states = states();
|
||||
let mut pad = padded(|x, y, z| {
|
||||
// Alternating leaf slabs make the search visit most of the volume.
|
||||
if (x / 2 + y / 2 + z / 2) % 3 == 0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
cube(&mut pad, 6, 10, 1);
|
||||
let chunk = loaded();
|
||||
let region = region(&chunk);
|
||||
let iterations = 1000;
|
||||
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
|
||||
let mut mask = LeafMask::default();
|
||||
let started = Instant::now();
|
||||
for _ in 0..iterations {
|
||||
mask.prepare(mode, &pad, &states, ®ion, 0);
|
||||
}
|
||||
let per = started.elapsed() / iterations;
|
||||
println!("leaf mask {mode:?}: {per:?} per section ({iterations} iterations)");
|
||||
assert!(mask.active);
|
||||
if mode == LeafCullingMode::SolidAggressive {
|
||||
assert_ne!(mask.depth[pi(8, 8, 8)], UNVISITED);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Leaf culling through the real mesher: face counts, the opaque layer and the
|
||||
//! conservative cases (cavities, partial neighbours, missing chunks, column
|
||||
//! ends). Needs prepared resources, like the other mesher geometry tests.
|
||||
use super::*;
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, ColumnLayout},
|
||||
graphics::LeafCullingMode,
|
||||
models,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
assets: Assets,
|
||||
biomes: Biomes,
|
||||
render: RenderAssets,
|
||||
visibility: horizon_world::block_render::FaceVisibility,
|
||||
tables: MeshTables,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
Fixture {
|
||||
assets,
|
||||
biomes,
|
||||
render,
|
||||
visibility,
|
||||
tables,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(f: &Fixture, name: &str) -> u32 {
|
||||
f.assets.registry.default_state(name).unwrap().0
|
||||
}
|
||||
|
||||
fn leaves_cube(chunk: &mut Chunk, min: i32, max: i32, y: i32, state: u32) {
|
||||
for z in min..=max {
|
||||
for x in min..=max {
|
||||
chunk.set(x, y, z, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf_cube(f: &Fixture, min: i32, max: i32, base: i32, name: &str) -> Chunk {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let state = state(f, name);
|
||||
for y in base..base + (max - min + 1) {
|
||||
leaves_cube(&mut chunk, min, max, y, state);
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
fn mesh(
|
||||
f: &Fixture,
|
||||
_chunk: &Chunk,
|
||||
region: [[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
mode: LeafCullingMode,
|
||||
) -> SectionMesh {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
assets: &f.assets,
|
||||
render: &f.render,
|
||||
tables: &f.tables,
|
||||
biomes: &f.biomes,
|
||||
visibility: f.visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
leaf_culling: mode,
|
||||
};
|
||||
mesher.mesh(®ion, sy)
|
||||
}
|
||||
|
||||
fn around(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
|
||||
[[Some(chunk); 3]; 3]
|
||||
}
|
||||
|
||||
/// (opaque, cutout) leaf quads, rejecting a quad whose four vertices disagree.
|
||||
fn quads(mesh: &SectionMesh) -> (usize, usize) {
|
||||
let mut opaque = 0;
|
||||
let mut cutout = 0;
|
||||
for quad in mesh.solid.as_chunks::<4>().0 {
|
||||
let flags = quad
|
||||
.iter()
|
||||
.map(|v| v.layer & OPAQUE_LAYER != 0)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
flags.iter().all(|flag| *flag == flags[0]),
|
||||
"a quad mixed opaque and cutout vertices: {flags:?}"
|
||||
);
|
||||
if flags[0] {
|
||||
opaque += 1;
|
||||
} else {
|
||||
cutout += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(mesh.solid.len() % 4, 0);
|
||||
(opaque, cutout)
|
||||
}
|
||||
|
||||
const BASE: i32 = MIN_Y + 4;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn off_draws_every_fancy_leaf_face() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
|
||||
let mesh = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
|
||||
// 27 blocks, six quads each: vanilla's fancy leaves keep the inside.
|
||||
assert_eq!(quads(&mesh), (0, 162));
|
||||
assert_eq!(mesh.translucent.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn solid_draws_the_fully_enclosed_leaf_opaque() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// Only the centre has all six sides covered: its six quads go opaque.
|
||||
assert_eq!(quads(&solid), (6, 156));
|
||||
|
||||
let aggressive = mesh(
|
||||
&f,
|
||||
&chunk,
|
||||
around(&chunk),
|
||||
0,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
);
|
||||
// Aggressive also takes the six face-centre leaves: seven blocks opaque.
|
||||
assert_eq!(quads(&aggressive), (42, 120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn aggressive_solidifies_the_next_shell_layer_of_a_five_cube() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// The 3x3x3 inside the surface: 27 blocks, six quads each.
|
||||
assert_eq!(quads(&solid), (162, 588));
|
||||
let aggressive = mesh(
|
||||
&f,
|
||||
&chunk,
|
||||
around(&chunk),
|
||||
0,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
);
|
||||
// The 3x3x3 inside plus the 3x3 face centre of each of the six sides.
|
||||
assert_eq!(quads(&aggressive), (486, 264));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn hollow_drops_only_the_faces_two_leaves_share() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
// 162 quads minus the 54 shared pairs' 108 faces: the 3x3x3 shell.
|
||||
assert_eq!(quads(&hollow), (0, 54));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn mixed_species_count_as_one_leaf_volume() {
|
||||
let f = fixture();
|
||||
let oak = state(&f, "minecraft:oak_leaves");
|
||||
let birch = state(&f, "minecraft:birch_leaves");
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in BASE..BASE + 3 {
|
||||
for z in 6..=8 {
|
||||
for x in 6..=8 {
|
||||
chunk.set(x, y, z, if (x + y + z) % 2 == 0 { oak } else { birch });
|
||||
}
|
||||
}
|
||||
}
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
assert_eq!(quads(&solid), (6, 156));
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
assert_eq!(quads(&hollow), (0, 54));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn a_cavity_keeps_its_six_faces_and_its_rim_stays_cutout() {
|
||||
let f = fixture();
|
||||
let mut chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
|
||||
chunk.set(7, BASE + 2, 7, 0);
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
// Five-cube shell (150) plus the six faces around the one-block cavity.
|
||||
assert_eq!(quads(&hollow), (0, 156));
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// The six leaves around the cavity are exposed, so they stay cutout; the
|
||||
// other 20 inner leaves draw solid. The cavity block itself has no quads.
|
||||
assert_eq!(quads(&solid), (120, 624));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn section_boundary_faces_stay_with_a_loaded_neighbour() {
|
||||
let f = fixture();
|
||||
let leaf = state(&f, "minecraft:oak_leaves");
|
||||
let chunk = leaf_cube(&f, 12, 15, BASE, "minecraft:oak_leaves");
|
||||
// A loaded east chunk whose x=0 column continues the canopy.
|
||||
let mut east = Chunk::empty(1, 0);
|
||||
for y in BASE..BASE + 4 {
|
||||
for z in 12..=15 {
|
||||
east.set(0, y, z, leaf);
|
||||
}
|
||||
}
|
||||
let mut region = around(&chunk);
|
||||
region[1][2] = Some(&east);
|
||||
let joined = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
|
||||
let mut region = around(&chunk);
|
||||
region[1][2] = None;
|
||||
let missing = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
|
||||
// The east faces of the x=15 leaves are on the section boundary: a loaded
|
||||
// neighbour does not let Hollow drop them, and a missing one does not
|
||||
// change the mesh either.
|
||||
assert_eq!(joined.solid.len(), missing.solid.len());
|
||||
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
|
||||
assert!(joined.solid.len() < off.solid.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn the_top_of_a_one_section_column_stays_visible() {
|
||||
let f = fixture();
|
||||
let leaf = state(&f, "minecraft:oak_leaves");
|
||||
let layout = ColumnLayout::new(0, 16).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
for y in 13..16 {
|
||||
leaves_cube(&mut chunk, 5, 7, y, leaf);
|
||||
}
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// y=15 is the column's top: the faces toward y=16 are unknown, so they
|
||||
// stay, and the y=15 leaves are not enclosed.
|
||||
assert!(!hollow.solid.is_empty());
|
||||
assert!(solid.solid.iter().any(|v| v.pos[1] == 16.0));
|
||||
assert!(
|
||||
solid
|
||||
.solid
|
||||
.iter()
|
||||
.filter(|v| v.pos[1] == 16.0)
|
||||
.all(|v| v.layer & OPAQUE_LAYER == 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn a_world_without_leaves_meshes_identically_in_every_mode() {
|
||||
let f = fixture();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in BASE..BASE + 3 {
|
||||
leaves_cube(&mut chunk, 6, 8, y, state(&f, "minecraft:stone"));
|
||||
}
|
||||
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
|
||||
for mode in LeafCullingMode::ALL {
|
||||
let other = mesh(&f, &chunk, around(&chunk), 0, mode);
|
||||
assert_eq!(off.solid.len(), other.solid.len(), "{mode:?}");
|
||||
assert_eq!(off.translucent.len(), other.translucent.len(), "{mode:?}");
|
||||
for (a, b) in off.solid.iter().zip(&other.solid) {
|
||||
assert_eq!(
|
||||
(a.pos, a.uv, a.layer, a.light, a.color),
|
||||
(b.pos, b.uv, b.layer, b.light, b.color),
|
||||
"{mode:?}"
|
||||
);
|
||||
}
|
||||
for (a, b) in off.translucent.iter().zip(&other.translucent) {
|
||||
assert_eq!(
|
||||
(a.pos, a.uv, a.layer, a.light, a.color),
|
||||
(b.pos, b.uv, b.layer, b.light, b.color),
|
||||
"{mode:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mut colours: Vec<u32> = mesher
|
||||
.mesh(®ion, 0)
|
||||
@@ -156,6 +157,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: reload == 0,
|
||||
cutout_leaves: reload == 0,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
for (case, (center, east)) in [
|
||||
(&empty, None),
|
||||
|
||||
@@ -88,6 +88,7 @@ fn info(solid: bool) -> Info {
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
opaque_cube: solid,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
|
||||
@@ -68,6 +68,9 @@ pub enum Ticking {
|
||||
/// `RidingEntitySoundInstance` for the player riding a happy ghast
|
||||
/// (`underwater_sound` false) or a nautilus (true).
|
||||
RidingEntity { entity: u32, underwater_sound: bool },
|
||||
/// A native mod's music layer (`MusicLayers`), whose volume its owner
|
||||
/// sets each tick.
|
||||
MusicLayer,
|
||||
}
|
||||
/// What a minecart's sound instances read about it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -233,6 +236,25 @@ impl SoundInstance {
|
||||
true,
|
||||
)
|
||||
}
|
||||
/// A native mod's music layer: a looping, unattenuated track on the music
|
||||
/// source that starts silent (`MusicLayers` fades it in).
|
||||
pub fn for_music_layer(id: &str, random: LegacyRandom) -> Self {
|
||||
let mut s = Self::new(
|
||||
id,
|
||||
MUSIC,
|
||||
0.0,
|
||||
1.0,
|
||||
random,
|
||||
true,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
true,
|
||||
);
|
||||
s.can_start_silent = true;
|
||||
s.ticking = Ticking::MusicLayer;
|
||||
s
|
||||
}
|
||||
/// `SimpleSoundInstance.forJukeboxSong`.
|
||||
pub fn for_jukebox_song(id: &str, at: [f64; 3], random: LegacyRandom) -> Self {
|
||||
Self::new(
|
||||
@@ -522,7 +544,7 @@ impl SoundInstance {
|
||||
/// `TickableSoundInstance.tick`.
|
||||
pub fn tick(&mut self, ctx: &TickContext) {
|
||||
match &mut self.ticking {
|
||||
Ticking::No => {}
|
||||
Ticking::No | Ticking::MusicLayer => {}
|
||||
Ticking::BiomeLoop { fade, direction } => {
|
||||
let stop = *fade < 0;
|
||||
*fade += *direction;
|
||||
|
||||
@@ -349,10 +349,26 @@ impl Sprites {
|
||||
};
|
||||
self.register(root, name, decoded)
|
||||
}
|
||||
/// The layer of a texture outside `textures/` (an OptiFine pack's connected-texture tile),
|
||||
/// named by its resource path and added on first use. Unlike [`Sprites::insert`], a texture
|
||||
/// that does not load is an error: the caller drops what needed it. Such files bypass the
|
||||
/// disk cache, whose fingerprint covers only `textures/`.
|
||||
pub fn insert_file(&mut self, root: &Path, resource: &str) -> Result<u32> {
|
||||
if let Some(layer) = self.layers.get(resource) {
|
||||
return Ok(*layer);
|
||||
}
|
||||
let decoded = Self::decode_file(&root.join(resource), resource)?;
|
||||
self.register(root, resource, decoded)
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
|
||||
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
Self::decode_file(
|
||||
&root.join(format!("assets/{namespace}/textures/{path}.png")),
|
||||
name,
|
||||
)
|
||||
}
|
||||
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
@@ -368,7 +384,7 @@ impl Sprites {
|
||||
.as_deref()
|
||||
.map(AnimationMetadata::from_json)
|
||||
.transpose()?;
|
||||
let (png_width, png_height, pixels) = read_rgba(&path)?;
|
||||
let (png_width, png_height, pixels) = read_rgba(path)?;
|
||||
let (width, height) = if let Some(animation) = &animation {
|
||||
let [width, height] =
|
||||
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
|
||||
@@ -662,6 +678,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn files_outside_textures_load_by_path() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sprite-file-{}", std::process::id()));
|
||||
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
|
||||
fs::create_dir_all(root.join(tile).parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(root.join(tile)).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..256).flat_map(|i| [i as u8, 0, 0, 128]).collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
let mut sprites = Sprites::default();
|
||||
let layer = sprites.insert_file(&root, tile).unwrap();
|
||||
assert_eq!(sprites.insert_file(&root, tile).unwrap(), layer);
|
||||
assert!(sprites.translucent[layer as usize]);
|
||||
// A missing tile is an error, not the missing texture.
|
||||
assert!(
|
||||
sprites
|
||||
.insert_file(&root, "assets/minecraft/optifine/ctm/glass/1.png")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(sprites.count(), 1);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn identical_still_textures_share_a_layer() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
graphics::LeafCullingMode,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
@@ -149,6 +150,8 @@ pub trait LodSampler: Send {
|
||||
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
|
||||
self.column(x, z)
|
||||
}
|
||||
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
|
||||
/// when sampling failed and should be tried again later.
|
||||
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
|
||||
let mut columns = Vec::with_capacity(side * side);
|
||||
for dz in 0..side {
|
||||
@@ -202,6 +205,7 @@ struct MeshSettings {
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
cutout_leaves: bool,
|
||||
leaf_culling: LeafCullingMode,
|
||||
}
|
||||
enum Job {
|
||||
Generate(i32, i32),
|
||||
@@ -436,6 +440,8 @@ pub struct Streaming {
|
||||
/// The `ao` and `cutoutLeaves` options.
|
||||
pub smooth_lighting: bool,
|
||||
pub cutout_leaves: bool,
|
||||
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
|
||||
light_tables: Arc<LightTables>,
|
||||
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
|
||||
@@ -770,6 +776,7 @@ impl Streaming {
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
@@ -851,6 +858,7 @@ impl Streaming {
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
light_tables,
|
||||
light_log: Vec::new(),
|
||||
light_edit_sequence: 0,
|
||||
@@ -1710,6 +1718,7 @@ impl Streaming {
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
cutout_leaves: self.cutout_leaves,
|
||||
leaf_culling: self.leaf_culling,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -591,6 +591,11 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
|
||||
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
|
||||
}
|
||||
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
|
||||
// far plane, written over the whole depth target with no colour.
|
||||
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
}
|
||||
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
let d = view_ray(in.ndc);
|
||||
let dist = disc_distance(d, -4.0);
|
||||
|
||||
@@ -16,7 +16,6 @@ horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
@@ -37,6 +36,7 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
|
||||
@@ -219,6 +219,7 @@
|
||||
"horizon.extras.header.third_person": "Third person",
|
||||
"horizon.extras.header.particles": "Particles",
|
||||
"horizon.extras.header.inventory": "Inventory",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.multiplayer": "Multiplayer",
|
||||
"horizon.extras.header.sound": "Sound",
|
||||
"horizon.extras.fullbright": "Fullbright",
|
||||
@@ -235,6 +236,8 @@
|
||||
"horizon.extras.agent_control": "Agent Control",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
|
||||
@@ -248,5 +251,7 @@
|
||||
"horizon.extras.no_ai.tooltip": "Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too.",
|
||||
"horizon.extras.agent_control.tooltip": "Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only.",
|
||||
"horizon.extras.discord_presence.tooltip": "Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address.",
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
}
|
||||
@@ -203,5 +203,11 @@
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
|
||||
"horizon.pages.shader_options": "Opciones de sombreadores",
|
||||
"horizon.pages.split_profiles": "¿Quién juega?",
|
||||
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
|
||||
"horizon.shader_options.open": "Opciones de sombreadores…",
|
||||
"horizon.shader_options.profile": "Perfil",
|
||||
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
|
||||
}
|
||||
@@ -18,5 +18,11 @@
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
|
||||
"horizon.pages.shader_options": "Ustawienia shaderów",
|
||||
"horizon.pages.split_profiles": "Kto gra?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
|
||||
"horizon.shader_options.open": "Ustawienia shaderów…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…"
|
||||
}
|
||||
@@ -202,5 +202,11 @@
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
|
||||
"horizon.pages.shader_options": "Настройки шейдеров",
|
||||
"horizon.pages.split_profiles": "Кто играет?",
|
||||
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
|
||||
"horizon.shader_options.open": "Настройки шейдеров…",
|
||||
"horizon.shader_options.profile": "Профиль",
|
||||
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
|
||||
}
|
||||
@@ -196,5 +196,11 @@
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Parçaları oluşturan, aydınlatan ve ağ verilerini hazırlayan iş parçacıklarının sayısı. Değişiklikler sonraki başlatmada uygulanır.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Önce Vulkan kullanılır. Çalışmazsa Windows'ta DirectX 12, Linux'ta OpenGL denenir. Değişiklikler sonraki başlatmada uygulanır.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Her karede GPU'ya gönderilen ağ verisi miktarı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir."
|
||||
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir.",
|
||||
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
|
||||
"horizon.pages.split_profiles": "Kim oynuyor?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
|
||||
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
|
||||
}
|
||||
@@ -18,5 +18,11 @@
|
||||
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
|
||||
"horizon.pages.shader_options": "Налаштування шейдерів",
|
||||
"horizon.pages.split_profiles": "Хто грає?",
|
||||
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
|
||||
"horizon.shader_options.open": "Налаштування шейдерів…",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…"
|
||||
}
|
||||
@@ -203,5 +203,11 @@
|
||||
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
|
||||
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
|
||||
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
|
||||
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
|
||||
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…",
|
||||
"horizon.pages.shader_options": "光影选项",
|
||||
"horizon.pages.split_profiles": "谁在玩?",
|
||||
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
|
||||
"horizon.shader_options.open": "光影选项…",
|
||||
"horizon.shader_options.profile": "档案",
|
||||
"horizon.shader_options.loading": "正在读取光影包的选项…"
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use horizon_client::{
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::HEIGHT,
|
||||
graphics::LeafCullingMode,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
models,
|
||||
@@ -139,6 +140,7 @@ fn main() -> Result<(), Error> {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
|
||||
@@ -328,5 +328,5 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
if let Some(n) = c["scroll"].as_i64() {
|
||||
chat.scroll_by(n as i32, focused);
|
||||
}
|
||||
chat.layout(h, c["now"].as_u64().unwrap_or(0), focused)
|
||||
chat.layout(h, c["now"].as_f64().unwrap_or(0.0) as f32, focused)
|
||||
}
|
||||
@@ -1182,6 +1182,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
thundering: s.thunder,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ use horizon_client::{
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
graphics::LeafCullingMode,
|
||||
mesher::{EMISSION_BITS, FLUID_FACE_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
models,
|
||||
};
|
||||
@@ -137,6 +138,7 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! progress (`PlayerAdvancements`) and the predicates criteria test
|
||||
//! (`EntityPredicate`, `ItemPredicate`, `LocationPredicate`, the loot
|
||||
//! conditions advancements use).
|
||||
use horizon_world::lazy_json::LazyJson;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
@@ -30,7 +31,9 @@ pub struct Display {
|
||||
pub struct Criterion {
|
||||
pub name: String,
|
||||
pub trigger: String,
|
||||
pub conditions: Value,
|
||||
/// Read on first test: ~1,300 recipe advancements' criteria, most never
|
||||
/// tested in a game, took ~4 MB as `Value`s.
|
||||
pub conditions: LazyJson,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rewards {
|
||||
@@ -154,7 +157,8 @@ impl Tree {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
match self.list[ai].criteria[ci].conditions["recipes"].as_str() {
|
||||
// `read`: the index alone keeps none of the criteria read.
|
||||
match self.list[ai].criteria[ci].conditions.read()["recipes"].as_str() {
|
||||
Some(r) => self
|
||||
.by_recipe
|
||||
.entry(r.to_owned())
|
||||
@@ -208,7 +212,8 @@ impl InventoryIndex {
|
||||
return index;
|
||||
};
|
||||
for &(ai, ci) in list {
|
||||
let items = &tree.list[ai].criteria[ci].conditions["items"];
|
||||
let conditions = tree.list[ai].criteria[ci].conditions.read();
|
||||
let items = &conditions["items"];
|
||||
// `id_matches`: only a string or a list limits the items.
|
||||
let limited = items.as_array().and_then(|a| {
|
||||
a.iter()
|
||||
@@ -266,7 +271,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
.map(|(name, c)| Criterion {
|
||||
name: name.clone(),
|
||||
trigger: c["trigger"].as_str().unwrap_or("").to_owned(),
|
||||
conditions: c["conditions"].clone(),
|
||||
conditions: LazyJson::new(&c["conditions"]),
|
||||
})
|
||||
.collect();
|
||||
let requirements = match v["requirements"].as_array() {
|
||||
@@ -1606,7 +1611,7 @@ mod tests {
|
||||
criteria: vec![Criterion {
|
||||
name: "c".into(),
|
||||
trigger: "minecraft:inventory_changed".into(),
|
||||
conditions,
|
||||
conditions: LazyJson::new(&conditions),
|
||||
}],
|
||||
requirements: vec![vec!["c".into()]],
|
||||
rewards: Rewards::default(),
|
||||
@@ -1655,7 +1660,7 @@ mod tests {
|
||||
criteria: vec![Criterion {
|
||||
name: "c".into(),
|
||||
trigger: "minecraft:recipe_unlocked".into(),
|
||||
conditions,
|
||||
conditions: LazyJson::new(&conditions),
|
||||
}],
|
||||
requirements: vec![vec!["c".into()]],
|
||||
rewards: Rewards::default(),
|
||||
|
||||
@@ -1219,6 +1219,15 @@ pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
|
||||
Ok(named)
|
||||
}
|
||||
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
|
||||
/// A recipe's whole id from its name in the tables: vanilla's are kept bare
|
||||
/// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`).
|
||||
pub fn recipe_id(name: &str) -> String {
|
||||
if name.contains(':') {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
}
|
||||
}
|
||||
fn custom_type(v: &Value) -> Option<&str> {
|
||||
v["type"]
|
||||
.as_str()
|
||||
@@ -1309,10 +1318,14 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
}
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
|
||||
let mut meta = HashMap::new();
|
||||
let sources = Self::sources(root, overlay)?;
|
||||
Self::from_sources(root, items, overlay, sources)
|
||||
}
|
||||
/// The recipe files the tables are built from, each id once: vanilla's by
|
||||
/// bare name, then the content mods' as `<ns>:<name>`, the world's packs'
|
||||
/// over them (`overlay`), parsed, and only those whose load conditions
|
||||
/// hold. A server sends some of them to its clients (`recipe_content`).
|
||||
pub fn sources(root: &Path, overlay: &PackOverlay) -> Result<Vec<(String, Value)>> {
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
|
||||
@@ -1357,6 +1370,7 @@ impl Recipes {
|
||||
}
|
||||
}
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
|
||||
let mut parsed = Vec::with_capacity(sources.len());
|
||||
for (name, bytes) in sources {
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
@@ -1375,13 +1389,26 @@ impl Recipes {
|
||||
continue;
|
||||
}
|
||||
// The data's recipe filters, over the final set.
|
||||
let full_id = match name.contains(':') {
|
||||
true => name.clone(),
|
||||
false => format!("minecraft:{name}"),
|
||||
};
|
||||
if !filters.apply(&full_id, &mut v) {
|
||||
if !filters.apply(&recipe_id(&name), &mut v) {
|
||||
continue;
|
||||
}
|
||||
parsed.push((name, v));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
/// The tables from `sources` (`Recipes::sources`' shape); `root` and
|
||||
/// `overlay` give the enchantment tags the tables keep.
|
||||
pub fn from_sources(
|
||||
root: &Path,
|
||||
items: &Items,
|
||||
overlay: &PackOverlay,
|
||||
sources: Vec<(String, Value)>,
|
||||
) -> Result<Self> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
|
||||
let mut meta = HashMap::new();
|
||||
for (name, v) in sources {
|
||||
if let Some(kind) = custom_type(&v) {
|
||||
let id = if name.contains(':') {
|
||||
name
|
||||
|
||||
@@ -201,6 +201,7 @@ impl ItemEntity {
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
entity_boxes: &[Aabb],
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -241,10 +242,20 @@ impl ItemEntity {
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else {
|
||||
} else if entity_boxes.is_empty() {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
} else {
|
||||
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still).
|
||||
crate::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: entity_boxes,
|
||||
border: None,
|
||||
}
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -405,6 +416,71 @@ impl ItemEntity {
|
||||
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
|
||||
}
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
|
||||
/// many vehicles checks a handful of boxes per item, not every box in the level.
|
||||
#[derive(Default)]
|
||||
pub struct ObstacleGrid {
|
||||
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { cells, boxes }
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
(
|
||||
(v.x / Self::CELL).floor() as i32,
|
||||
(v.y / Self::CELL).floor() as i32,
|
||||
(v.z / Self::CELL).floor() as i32,
|
||||
)
|
||||
};
|
||||
(cell(b.min_position()), cell(b.max_position()))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (lo, hi) = Self::span(area);
|
||||
let mut found: Vec<u32> = Vec::new();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
out.extend(
|
||||
found
|
||||
.into_iter()
|
||||
.map(|i| self.boxes[i as usize])
|
||||
.filter(|b| b.intersects(area)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
|
||||
/// search around an item. Holds indexes into the tick's item list.
|
||||
#[derive(Default)]
|
||||
@@ -1081,6 +1157,12 @@ pub struct ArmorStand {
|
||||
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
|
||||
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
|
||||
pub disabled_slots: i32,
|
||||
/// Server-only `LivingEntity.lastPos` for location-based enchantments.
|
||||
pub enchantment_block: Option<BlockPos>,
|
||||
/// Travel accumulated for `LivingEntity.checkFallDamage`'s landing callback.
|
||||
pub enchantment_fall_distance: f64,
|
||||
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
|
||||
pub enchantment_equipment: [Option<ItemStack>; 6],
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
@@ -1152,6 +1234,9 @@ impl ArmorStand {
|
||||
pose: Self::DEFAULT_POSE,
|
||||
vehicle: None,
|
||||
disabled_slots: 0,
|
||||
enchantment_block: None,
|
||||
enchantment_fall_distance: 0.0,
|
||||
enchantment_equipment: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
|
||||
@@ -1721,7 +1806,15 @@ mod honey_slide_tests {
|
||||
item.id = 1;
|
||||
let mut ys = Vec::new();
|
||||
for _ in 0..8 {
|
||||
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
ys.push(item.velocity.y);
|
||||
}
|
||||
// Idle ticks keep gravity; the 4th tick's move hits the floor and
|
||||
@@ -1777,7 +1870,15 @@ mod honey_slide_tests {
|
||||
item.id = 1;
|
||||
let before = item.position.y;
|
||||
for _ in 0..16 {
|
||||
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
item.position.y > before + 0.5,
|
||||
@@ -1787,7 +1888,7 @@ mod honey_slide_tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
id: 1,
|
||||
@@ -1995,7 +2096,15 @@ mod slime_bounce_tests {
|
||||
falling.on_ground = false;
|
||||
falling.age = 0;
|
||||
falling.id = 1;
|
||||
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
falling.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
assert_eq!(falling.position.y, 4.0);
|
||||
assert!(falling.velocity.y > 0.0);
|
||||
assert!(falling.on_ground);
|
||||
@@ -2005,7 +2114,15 @@ mod slime_bounce_tests {
|
||||
// does not set needs_sync.
|
||||
falling.velocity = Vec3::ZERO;
|
||||
falling.needs_sync = false;
|
||||
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
falling.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
assert!(!falling.needs_sync);
|
||||
}
|
||||
}
|
||||
@@ -2060,3 +2177,117 @@ mod unstuck_tests {
|
||||
assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod entity_collision_tests {
|
||||
use super::honey_slide_tests::falling_item;
|
||||
use super::*;
|
||||
|
||||
/// `Entity.collide` stops an item at the entities that `canBeCollidedWith` it: dropped
|
||||
/// above a boat's box (or a still happy ghast's) it lands on the box's top and rests,
|
||||
/// where with no box it falls to the floor of the scene.
|
||||
#[test]
|
||||
fn an_item_lands_on_an_entity_box_under_it() {
|
||||
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 = crate::motion::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();
|
||||
world.set(20, 3, 20, stone).unwrap();
|
||||
let rest = |boxes: &[Aabb]| {
|
||||
let mut item = falling_item(20.5, 12.0, 20.5, 0.0, 0.0, 0.0);
|
||||
item.id = 1;
|
||||
for _ in 0..80 {
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
boxes,
|
||||
);
|
||||
}
|
||||
item.position.y
|
||||
};
|
||||
// The stone's top is at 4.0; a box from 5.0 to 9.0 is in the way.
|
||||
let platform = Aabb::new(Vec3::new(19.0, 5.0, 19.0), Vec3::new(22.0, 9.0, 22.0));
|
||||
assert!((rest(&[]) - 4.0).abs() < 1.0e-6, "on the stone with no box");
|
||||
assert!(
|
||||
(rest(&[platform]) - 9.0).abs() < 1.0e-6,
|
||||
"on top of the box, at its top"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obstacle_grid_returns_only_the_boxes_near_an_item() {
|
||||
// A boat every 100 blocks along a line, 1000 of them.
|
||||
let boat = |x: f64| {
|
||||
Aabb::new(
|
||||
Vec3::new(x, 64.0, 0.0),
|
||||
Vec3::new(x + 1.375, 64.5625, 1.375),
|
||||
)
|
||||
};
|
||||
let grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
|
||||
let mut out = Vec::new();
|
||||
// Around the 7th boat: only it.
|
||||
let area = Aabb::new(Vec3::new(699.0, 63.0, -1.0), Vec3::new(701.0, 66.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert_eq!(out, vec![boat(700.0)]);
|
||||
// Between two boats: none.
|
||||
let area = Aabb::new(Vec3::new(750.0, 63.0, 0.0), Vec3::new(751.0, 66.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert!(out.is_empty());
|
||||
// A box spanning cells is found from any of them, once.
|
||||
let wide = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 1.0, 1.0));
|
||||
let grid = ObstacleGrid::new(vec![wide]);
|
||||
let area = Aabb::new(Vec3::new(17.0, 0.0, 0.0), Vec3::new(18.0, 1.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert_eq!(out, vec![wide]);
|
||||
}
|
||||
|
||||
/// A farm: 5000 drops and 1000 separated vehicles. Run with `-- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "timing"]
|
||||
fn obstacle_grid_beats_scanning_every_box_for_a_big_farm() {
|
||||
let boat = |i: u32| {
|
||||
let (x, z) = (f64::from(i % 40) * 6.0, f64::from(i / 40) * 6.0);
|
||||
Aabb::new(
|
||||
Vec3::new(x, 64.0, z),
|
||||
Vec3::new(x + 1.375, 64.5625, z + 1.375),
|
||||
)
|
||||
};
|
||||
let boxes: Vec<Aabb> = (0..1000).map(boat).collect();
|
||||
let items: Vec<Aabb> = (0..5000)
|
||||
.map(|i| {
|
||||
let (x, z) = (f64::from(i % 70) * 3.4, f64::from(i / 70) * 3.4);
|
||||
Aabb::new(Vec3::new(x, 64.0, z), Vec3::new(x + 0.25, 64.25, z + 0.25))
|
||||
})
|
||||
.collect();
|
||||
let start = std::time::Instant::now();
|
||||
let mut hits = 0;
|
||||
for item in &items {
|
||||
let area = item.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
hits += boxes.iter().filter(|b| b.intersects(area)).count();
|
||||
}
|
||||
let scan = start.elapsed();
|
||||
let grid = ObstacleGrid::new(boxes);
|
||||
let start = std::time::Instant::now();
|
||||
let (mut out, mut found) = (Vec::new(), 0);
|
||||
for item in &items {
|
||||
grid.near(item.inflate(Vec3::new(1.0, 1.0, 1.0)), &mut out);
|
||||
found += out.len();
|
||||
}
|
||||
let binned = start.elapsed();
|
||||
assert_eq!(hits, found);
|
||||
println!("scan {scan:?}, grid {binned:?}");
|
||||
}
|
||||
}
|
||||
@@ -246,17 +246,41 @@ mod tests {
|
||||
needs_sync: false,
|
||||
};
|
||||
let mut still = item.clone();
|
||||
assert!(still.tick(&world, assets.registry, &facts, &collision, false, &fluids));
|
||||
assert!(still.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut carried = item;
|
||||
assert!(carried.tick(&world, assets.registry, &facts, &collision, false, &fluids));
|
||||
assert!(carried.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
carried.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
carried.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ mod names;
|
||||
mod narrator;
|
||||
mod native_biome_colors;
|
||||
mod native_block_entity_draw;
|
||||
mod native_claimed_items;
|
||||
mod native_components;
|
||||
mod native_criteria;
|
||||
mod native_entities;
|
||||
@@ -237,6 +238,7 @@ mod prediction;
|
||||
mod pumpkin;
|
||||
mod quick_actions;
|
||||
mod raids;
|
||||
mod random_textures;
|
||||
mod recipe_book;
|
||||
mod remote;
|
||||
mod remote_camera;
|
||||
@@ -268,6 +270,7 @@ mod shield;
|
||||
mod shoulders;
|
||||
mod sign_edit;
|
||||
mod sniffers;
|
||||
mod social;
|
||||
mod spawner;
|
||||
mod spear;
|
||||
mod spectator;
|
||||
@@ -760,6 +763,8 @@ enum Screen {
|
||||
/// Play with Friends, from the pause menu's Friends button
|
||||
/// (`friends_screen`, game/friends.rs).
|
||||
Friends,
|
||||
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
|
||||
Social,
|
||||
/// CommandBlockEditScreen (game/command_blocks.rs).
|
||||
CommandBlock,
|
||||
/// A game master block's edit screen (game/block_editor.rs).
|
||||
@@ -796,6 +801,7 @@ impl Screen {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world_options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::CommandBlock => "command_block",
|
||||
Screen::BlockEditor => "block_editor",
|
||||
Screen::ServerLinks => "server_links",
|
||||
@@ -2198,6 +2204,7 @@ struct Game<'a> {
|
||||
// Physical input is separate from remapped and toggled movement keys.
|
||||
physical_keys: HashSet<KeyCode>,
|
||||
physical_buttons: HashSet<MouseButton>,
|
||||
drag_button: Option<MouseButton>,
|
||||
/// Control + left click as a right click (macOS).
|
||||
right_click_emulation: horizon_client_input::mouse::RightClickEmulation,
|
||||
left: bool,
|
||||
@@ -2301,6 +2308,8 @@ struct Game<'a> {
|
||||
session_lock: Option<horizon_client::save::SessionLock>,
|
||||
audio: Audio,
|
||||
music: horizon_client::music::MusicManager,
|
||||
/// The tracks native mods play over the music (`native_music_layers`).
|
||||
music_layers: horizon_client::music::MusicLayers,
|
||||
/// `ToastManager.nowPlayingToast`: there while Music Toast is not Never.
|
||||
now_playing: Option<horizon_client::toasts::NowPlayingToast>,
|
||||
/// The Music Toast option the toast was last set up for.
|
||||
@@ -2618,6 +2627,8 @@ struct Game<'a> {
|
||||
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
|
||||
/// The screen of a mod block's menu open on this client (`native_screen`).
|
||||
mod_screen: Option<native_hooks::OpenNativeScreen>,
|
||||
/// The players with each mod container open, by level (`tick_mod_container_viewers`).
|
||||
mod_container_viewers: HashMap<(&'static str, (i32, i32, i32)), u32>,
|
||||
/// Where item entities are, while the block entities tick (hoppers).
|
||||
item_columns: Option<machines::ItemColumns>,
|
||||
creative_screen: Creative,
|
||||
@@ -2644,6 +2655,8 @@ struct Game<'a> {
|
||||
panel: native_panel::Panel,
|
||||
/// Horizon's own shader pack, while one is picked (`game/native_shaders.rs`).
|
||||
native_shaders: native_shaders::NativeShaders,
|
||||
/// The items native client mods claim and the events they haven't polled (game/native_claimed_items.rs).
|
||||
native_input: native_claimed_items::ModInput,
|
||||
/// The native lifecycle bookkeeping.
|
||||
native_lifecycle: native_lifecycle::NativeLifecycle,
|
||||
/// Native mods' messages waiting (`game/native_net.rs`).
|
||||
@@ -2685,6 +2698,8 @@ struct Game<'a> {
|
||||
serving: Option<integrated::Serving>,
|
||||
/// The open world is shared behind an invite code (game/friends.rs).
|
||||
friends: Option<friends::Sharing>,
|
||||
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
|
||||
social: social::Social,
|
||||
world_options: Option<lan_screen::WorldOptions>,
|
||||
/// Chat Settings opened from RestrictionsScreen: Done returns to World Options.
|
||||
options_to_world_options: bool,
|
||||
@@ -3687,6 +3702,8 @@ impl Game<'_> {
|
||||
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
|
||||
// A LAN or server guest only shows the host's items.
|
||||
let merging = self.remote.is_none();
|
||||
let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
let mut nearby = Vec::new();
|
||||
for i in 0..items.len() {
|
||||
if removed[i] {
|
||||
continue;
|
||||
@@ -3711,6 +3728,14 @@ impl Game<'_> {
|
||||
let speed = self.world.random.next_float() * 0.2 + 0.1;
|
||||
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
|
||||
}
|
||||
// `getEntityCollisions(entity, bounds.expandTowards(movement))`: the boxes around
|
||||
// where the item can reach this tick (a block of slack for the next velocity).
|
||||
let reach = Vec3::new(
|
||||
item.velocity.x.abs() + 1.0,
|
||||
item.velocity.y.abs() + 1.0,
|
||||
item.velocity.z.abs() + 1.0,
|
||||
);
|
||||
obstacles.near(item.bounds().inflate(reach), &mut nearby);
|
||||
let alive = item.tick(
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
@@ -3718,6 +3743,7 @@ impl Game<'_> {
|
||||
self.collision,
|
||||
no_physics,
|
||||
&self.res.motion().fluids,
|
||||
&nearby,
|
||||
);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// ItemEntity.tick: merge every 2 ticks while moving between
|
||||
@@ -5576,10 +5602,11 @@ impl Game<'_> {
|
||||
return Ok(Some(Hand::Main));
|
||||
}
|
||||
// An EntityHitResult may fall through to Item.use, never to useItemOn.
|
||||
let block_hit = if matches!(guest, Some(GuestUse::Item(_))) || self.entity_targeted() {
|
||||
None
|
||||
} else {
|
||||
self.pick()
|
||||
let block_hit = match guest {
|
||||
Some(GuestUse::OnBlock(_)) => self.pick(),
|
||||
Some(GuestUse::Item(_)) => None,
|
||||
_ if self.entity_targeted() => None,
|
||||
_ => self.pick(),
|
||||
};
|
||||
let hands = match guest {
|
||||
Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand, _)) => {
|
||||
@@ -5858,7 +5885,9 @@ impl Game<'_> {
|
||||
self.trigger_default_block_use(hit.pos);
|
||||
}
|
||||
}
|
||||
self.swing_arm();
|
||||
if result.should_swing() {
|
||||
self.swing_arm();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@@ -5956,9 +5985,7 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
}
|
||||
if (!on_block && self.use_potion_item(&stack)?)
|
||||
|| ((block_hit.is_some() || !spawn_egg)
|
||||
&& (!on_block || spawn_egg)
|
||||
&& self.throw_item(&stack)?)
|
||||
|| ((!on_block || (spawn_egg && block_hit.is_some())) && self.throw_item(&stack)?)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -7374,6 +7401,36 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Apply a physical key to every mapping bound to it.
|
||||
fn mapped_key_event(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(k) => {
|
||||
self.mapped_key(k, pressed, repeat)?;
|
||||
}
|
||||
Err(button) => {
|
||||
let action = match (pressed, repeat) {
|
||||
(false, _) => KeyAction::Release,
|
||||
(_, false) => KeyAction::Press,
|
||||
(_, true) => KeyAction::Repeat,
|
||||
};
|
||||
if let Some(pressed) = keyboard_button(
|
||||
gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.dialog_open(),
|
||||
),
|
||||
action,
|
||||
) {
|
||||
self.play_mouse(button, pressed)?;
|
||||
} else if container_key_clones(button, action) {
|
||||
self.inventory_pick_key();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// A key event after rebinding, as the default key of its mapping (the
|
||||
/// keyboard and controllers both end here).
|
||||
fn mapped_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) -> Result<()> {
|
||||
@@ -7479,6 +7536,7 @@ impl Game<'_> {
|
||||
| Screen::Advancements
|
||||
| Screen::WorldOptions
|
||||
| Screen::Friends
|
||||
| Screen::Social
|
||||
| Screen::ServerLinks
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
@@ -8178,6 +8236,7 @@ impl Game<'_> {
|
||||
Screen::Mod => {}
|
||||
Screen::WorldOptions => self.draw_world_options(&mut batch),
|
||||
Screen::Friends => self.draw_friends_screen(&mut batch),
|
||||
Screen::Social => self.draw_social_screen(&mut batch),
|
||||
Screen::ServerLinks => self.draw_server_links(&mut batch),
|
||||
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
|
||||
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
|
||||
@@ -11464,6 +11523,7 @@ impl Game<'_> {
|
||||
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
|
||||
// its chunks and players are.
|
||||
self.native_server_data_loaded(false);
|
||||
self.sync_recipe_content();
|
||||
self.bosses = Default::default();
|
||||
reset_world_progress(&mut self.adv, &mut self.stats);
|
||||
self.prefetch_advancement_tree();
|
||||
@@ -13339,14 +13399,25 @@ impl Game<'_> {
|
||||
let abilities = self.chat_abilities();
|
||||
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
|
||||
self.streaming.radius = self.settings.render_distance;
|
||||
// Options.biomeBlendRadius, ambientOcclusion and cutoutLeaves:
|
||||
// LevelExtractor.allChanged when they change.
|
||||
// Options.biomeBlendRadius, ambientOcclusion, cutoutLeaves and the
|
||||
// leaf-culling mode: LevelExtractor.allChanged when they change.
|
||||
let leaf_culling = self.effective_leaf_culling();
|
||||
let (s, st) = (&self.settings, &self.streaming);
|
||||
let remesh = (s.biome_blend, s.smooth_lighting, s.cutout_leaves)
|
||||
!= (st.biome_blend, st.smooth_lighting, st.cutout_leaves);
|
||||
let remesh = (
|
||||
s.biome_blend,
|
||||
s.smooth_lighting,
|
||||
s.cutout_leaves,
|
||||
leaf_culling,
|
||||
) != (
|
||||
st.biome_blend,
|
||||
st.smooth_lighting,
|
||||
st.cutout_leaves,
|
||||
st.leaf_culling,
|
||||
);
|
||||
self.streaming.biome_blend = self.settings.biome_blend;
|
||||
self.streaming.smooth_lighting = self.settings.smooth_lighting;
|
||||
self.streaming.cutout_leaves = self.settings.cutout_leaves;
|
||||
self.streaming.leaf_culling = leaf_culling;
|
||||
if remesh {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
@@ -13799,6 +13870,8 @@ impl Game<'_> {
|
||||
}
|
||||
lines
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.extractRenderState`: item slots share the
|
||||
/// container screen's hover target for their highlight and tooltip.
|
||||
fn draw_creative(&mut self, batch: &mut HudBatch) {
|
||||
// CreativeModeInventoryScreen: `CommonComponents.EMPTY` for a title.
|
||||
screen_info::begin_container("creative");
|
||||
@@ -13894,6 +13967,9 @@ impl Game<'_> {
|
||||
}
|
||||
// The hovered stack, and whether it is a grid slot (`CustomCreativeSlot`).
|
||||
let mut hovered: Option<(ItemStack, bool)> = None;
|
||||
// Use the same padded slot bounds as clicks and the other menus, so
|
||||
// highlights and tooltips remain visible between adjacent items.
|
||||
let hovered_slot = self.slot_at(batch, self.cursor.0, self.cursor.1);
|
||||
if tab.inventory {
|
||||
self.draw_player_preview(
|
||||
batch,
|
||||
@@ -13924,10 +14000,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 18.0 + r as f32 * 18.0);
|
||||
let over = self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0;
|
||||
let over = hovered_slot == Some(Creative::DISPLAY + r * 9 + c);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
@@ -13941,10 +14014,7 @@ impl Game<'_> {
|
||||
let drag = self.drag_view();
|
||||
for c in 0..9 {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
let over = self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0;
|
||||
let over = hovered_slot == Some(c);
|
||||
if over {
|
||||
self.draw_slot_highlight(batch, false, x, y);
|
||||
}
|
||||
@@ -15243,6 +15313,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
|
||||
Screen::Social => self.close_social(),
|
||||
Screen::ServerLinks => self.screen = Screen::Paused,
|
||||
// ConfirmScreen.keyPressed: Escape answers no; both return to
|
||||
// the Server Links screen they came from.
|
||||
@@ -15285,6 +15356,11 @@ impl Game<'_> {
|
||||
// an active use in the same tick.
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
// key.socialInteractions.
|
||||
KeyCode::KeyP if self.screen == Screen::Playing => {
|
||||
self.open_social(false);
|
||||
self.release_use_for_keybind_screen();
|
||||
}
|
||||
KeyCode::KeyL if self.screen == Screen::Advancements => self.close_advancements(),
|
||||
// key.quickActions: only open if a datapack provides dialog actions.
|
||||
KeyCode::KeyG if self.screen == Screen::Playing => {
|
||||
@@ -15472,11 +15548,25 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// LOD Distance and Far Detail Reduced as the player chose them.
|
||||
/// LOD Distance and Far Detail Reduced as the player chose them, the
|
||||
/// distance no farther than a server sampling far terrain for us allows.
|
||||
fn far_terrain_choice(&self) -> (u32, bool) {
|
||||
let g = &self.settings.horizon;
|
||||
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
|
||||
(g.lod_distance, reduced)
|
||||
let distance = match self.server_far_terrain() {
|
||||
Some(offer) => g.lod_distance.min(offer.distance),
|
||||
None => g.lod_distance,
|
||||
};
|
||||
(distance, reduced)
|
||||
}
|
||||
/// The far terrain another machine's server samples for us
|
||||
/// (`lod_net`): a client of a dedicated server or of a LAN world, not of
|
||||
/// its own server thread, which hands its generator over directly.
|
||||
fn server_far_terrain(&self) -> Option<horizon_client::lod_net::Offer> {
|
||||
if self.integrated.is_some() {
|
||||
return None;
|
||||
}
|
||||
self.remote.as_ref()?.far_terrain
|
||||
}
|
||||
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
|
||||
fn lod_distance(&self) -> u32 {
|
||||
@@ -15497,6 +15587,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn clear_lod(&mut self) {
|
||||
// The samplers waiting on the server stop first: dropping the far
|
||||
// terrain joins them.
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.session.cancel_far_terrain();
|
||||
}
|
||||
self.lod = None;
|
||||
self.lod_source_attempted = false;
|
||||
self.lod_observe.clear();
|
||||
@@ -15511,7 +15606,9 @@ impl Game<'_> {
|
||||
&& self.renderer.is_some()
|
||||
&& self.player.is_some()
|
||||
&& self.title.is_none()
|
||||
&& (self.remote.is_none() || self.integrated.is_some());
|
||||
&& (self.remote.is_none()
|
||||
|| self.integrated.is_some()
|
||||
|| self.server_far_terrain().is_some());
|
||||
if !enabled {
|
||||
if self.lod.is_some() || self.lod_source_attempted {
|
||||
self.clear_lod();
|
||||
@@ -15523,9 +15620,16 @@ impl Game<'_> {
|
||||
}
|
||||
self.lod_source_attempted = true;
|
||||
let dim = self.dim;
|
||||
let source = match &self.integrated {
|
||||
Some(server) => server.call(move |game| game.lod_source_in(dim)).flatten(),
|
||||
None => Some(self.streaming.lod_source()),
|
||||
let source = match (&self.integrated, self.server_far_terrain(), &self.remote) {
|
||||
(Some(server), ..) => server.call(move |game| game.lod_source_in(dim)).flatten(),
|
||||
(None, Some(offer), Some(remote)) => {
|
||||
let states = self.res.facts().states.len() as u32;
|
||||
let biomes = self.res.biomes().index.clone();
|
||||
let source: Arc<dyn horizon_client::streaming::LodSource> =
|
||||
remote.session.far_terrain(offer, biomes, states);
|
||||
Some(source)
|
||||
}
|
||||
_ => Some(self.streaming.lod_source()),
|
||||
};
|
||||
if let Some(source) = source {
|
||||
self.lod = Some(horizon_client::lod::Lod::new(
|
||||
@@ -15791,7 +15895,21 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.previous = Instant::now();
|
||||
}
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) {
|
||||
let key = if self.window.as_ref().is_some_and(|w| w.id() == id)
|
||||
&& let WindowEvent::KeyboardInput {
|
||||
event,
|
||||
is_synthetic: false,
|
||||
..
|
||||
} = &event
|
||||
{
|
||||
Some((event.clone(), glfw_modifiers(self.modifiers)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.handle_window_event(event_loop, id, event);
|
||||
if let Some((event, modifiers)) = key {
|
||||
self.native_input_key(&event, modifiers);
|
||||
}
|
||||
}
|
||||
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
|
||||
// The real mouse never steers an offscreen game: its pointer is not grabbed.
|
||||
@@ -15894,6 +16012,7 @@ impl Game<'_> {
|
||||
if let WindowEvent::Focused(false) = &event {
|
||||
self.physical_keys.clear();
|
||||
self.physical_buttons.clear();
|
||||
self.drag_button = None;
|
||||
}
|
||||
if let WindowEvent::ModifiersChanged(m) = &event {
|
||||
self.modifiers = m.state();
|
||||
@@ -16160,6 +16279,21 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let result: Result<()> = (|| {
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &event
|
||||
&& !self.pad_synthetic_input()
|
||||
{
|
||||
self.track_drag_button(*button, *state == ElementState::Pressed);
|
||||
}
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& event.state == ElementState::Released
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
&& self.screen_key_released(key)
|
||||
{
|
||||
// Screen cancellation must not leave a held keyboard mapping down.
|
||||
self.keys.remove(&key);
|
||||
self.mapped_key_event(key, false, false)?;
|
||||
return Ok(());
|
||||
}
|
||||
// ScreenEvent.MouseButtonPressed.Pre / MouseButtonReleased.Pre:
|
||||
// the open screen hears of the press before anything reads it, and
|
||||
// a cancelled one goes no further — no hotbar key, no slot, no
|
||||
@@ -16252,7 +16386,11 @@ impl Game<'_> {
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
if let Some(r) = &self.renderer {
|
||||
let b = HudBatch::new([r.config.width, r.config.height]);
|
||||
let previous = self.cursor;
|
||||
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
|
||||
if self.screen_mouse_dragged(previous) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.dialog_open() {
|
||||
self.dialog_drag();
|
||||
}
|
||||
@@ -16352,10 +16490,28 @@ impl Game<'_> {
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
&& !self.native_wheel_event(d)
|
||||
&& let Some((wheel, (_, vertical_steps))) =
|
||||
self.mouse
|
||||
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
|
||||
&& let Some((wheel, (horizontal_steps, vertical_steps))) = self
|
||||
.mouse
|
||||
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
|
||||
{
|
||||
if !self.pad_synthetic_input() {
|
||||
let mut canceled = false;
|
||||
self.native_mouse_scrolling(
|
||||
[scaled(raw_x), scaled(raw_y)],
|
||||
[horizontal_steps, vertical_steps],
|
||||
[
|
||||
self.physical_buttons.contains(&MouseButton::Left),
|
||||
self.physical_buttons.contains(&MouseButton::Middle),
|
||||
self.physical_buttons.contains(&MouseButton::Right),
|
||||
],
|
||||
[self.cursor.0, self.cursor.1],
|
||||
glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
if canceled {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if self.spectator() && self.spectator_gui.is_menu_active() {
|
||||
self.spectator_scroll(-wheel);
|
||||
} else if self.spectator() {
|
||||
@@ -16377,6 +16533,9 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Chat && d != 0.0 {
|
||||
self.chat_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Social && d != 0.0 {
|
||||
self.social_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::CommandBlock && d != 0.0 {
|
||||
self.command_block_scroll(d);
|
||||
}
|
||||
@@ -16626,6 +16785,9 @@ impl Game<'_> {
|
||||
Screen::Friends if pressed && button == MouseButton::Left => {
|
||||
self.friends_screen_click()
|
||||
}
|
||||
Screen::Social if pressed && button == MouseButton::Left => {
|
||||
self.social_click()
|
||||
}
|
||||
Screen::CommandBlock if pressed && button == MouseButton::Left => {
|
||||
self.command_block_click()
|
||||
}
|
||||
@@ -16783,6 +16945,9 @@ impl Game<'_> {
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Win => {
|
||||
self.win_key(&event)?
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Social => {
|
||||
self.social_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
|
||||
self.command_block_key(&event)
|
||||
}
|
||||
@@ -16923,38 +17088,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.smooth_camera = !self.smooth_camera;
|
||||
}
|
||||
// Key binds: act as the default key of every mapping bound
|
||||
// to it (KeyMapping.set drives them all).
|
||||
for mapped in self.settings.keys.translate_all(key) {
|
||||
match mapped {
|
||||
Ok(k) => {
|
||||
self.mapped_key(
|
||||
k,
|
||||
event.state == ElementState::Pressed,
|
||||
event.repeat,
|
||||
)?;
|
||||
}
|
||||
Err(button) => {
|
||||
let action = match (event.state, event.repeat) {
|
||||
(ElementState::Released, _) => KeyAction::Release,
|
||||
(_, false) => KeyAction::Press,
|
||||
(_, true) => KeyAction::Repeat,
|
||||
};
|
||||
if let Some(pressed) = keyboard_button(
|
||||
gameplay_has_keyboard(
|
||||
self.screen,
|
||||
self.title.is_some(),
|
||||
self.dialog_open(),
|
||||
),
|
||||
action,
|
||||
) {
|
||||
self.play_mouse(button, pressed)?;
|
||||
} else if container_key_clones(button, action) {
|
||||
self.inventory_pick_key();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.observe_fullscreen();
|
||||
@@ -17393,6 +17527,7 @@ impl<'a> Game<'a> {
|
||||
keys: HashSet::new(),
|
||||
physical_keys: HashSet::new(),
|
||||
physical_buttons: HashSet::new(),
|
||||
drag_button: None,
|
||||
right_click_emulation: Default::default(),
|
||||
left: false,
|
||||
right: false,
|
||||
@@ -17458,6 +17593,7 @@ impl<'a> Game<'a> {
|
||||
);
|
||||
music
|
||||
},
|
||||
music_layers: Default::default(),
|
||||
// ToastManager.initializeMusicToast: Pause and Pause and Toast make the toast.
|
||||
now_playing: (settings.file.int("musicToast").clamp(0, 2) != 0).then(Default::default),
|
||||
music_toast_state: settings.file.int("musicToast").clamp(0, 2) as u8,
|
||||
@@ -17646,6 +17782,7 @@ impl<'a> Game<'a> {
|
||||
tnts: Vec::new(),
|
||||
hoppers: HashMap::new(),
|
||||
mod_screen: None,
|
||||
mod_container_viewers: HashMap::new(),
|
||||
item_columns: None,
|
||||
creative_screen: prepared.creative_screen,
|
||||
control: None,
|
||||
@@ -17669,6 +17806,7 @@ impl<'a> Game<'a> {
|
||||
recipe_tables: Default::default(),
|
||||
panel: Default::default(),
|
||||
native_shaders: Default::default(),
|
||||
native_input: Default::default(),
|
||||
native_lifecycle: Default::default(),
|
||||
native_net: Default::default(),
|
||||
native_mod_screen: None,
|
||||
@@ -17691,6 +17829,7 @@ impl<'a> Game<'a> {
|
||||
test_highlights: HashMap::new(),
|
||||
game_tests: game_tests::GameTests::default(),
|
||||
friends: None,
|
||||
social: Default::default(),
|
||||
debug_keys: Default::default(),
|
||||
debug_charts: Default::default(),
|
||||
debug_entries: horizon_client::debug_entries::DebugEntries::load(
|
||||
@@ -18055,7 +18194,7 @@ impl Game<'_> {
|
||||
fn recipes(&self) -> &Recipes {
|
||||
match &self.world_tables {
|
||||
Some(t) => &t.recipes,
|
||||
None => self.res.recipes(),
|
||||
None => self.synced_recipes().unwrap_or_else(|| self.res.recipes()),
|
||||
}
|
||||
}
|
||||
fn stations(&self) -> &horizon_client::stations::StationData {
|
||||
@@ -18128,6 +18267,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.world_tables = self.build_world_tables();
|
||||
horizon_client::structure_blocks::forget_loot_data();
|
||||
// Before the players are sent the reloaded data below.
|
||||
self.sync_recipe_content();
|
||||
// PlayerList.reloadResources: the tags and recipes go to the players
|
||||
// and their advancements reload. The synchronized registries (the
|
||||
// dialogs, and the commands' ids of them) stay the hub's.
|
||||
@@ -20097,6 +20238,7 @@ mod tests {
|
||||
Screen::Advancements,
|
||||
Screen::WorldOptions,
|
||||
Screen::Friends,
|
||||
Screen::Social,
|
||||
Screen::CommandBlock,
|
||||
Screen::BlockEditor,
|
||||
Screen::ServerLinks,
|
||||
|
||||
@@ -2006,7 +2006,7 @@ impl Game<'_> {
|
||||
criteria.push(adv::Criterion {
|
||||
name: name.clone(),
|
||||
trigger: String::new(),
|
||||
conditions: Value::Null,
|
||||
conditions: Value::Null.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,6 +417,7 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
|
||||
@@ -727,20 +727,20 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
pub(super) fn tick_armor_stands(&mut self) {
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut dead = Vec::new();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
for (i, a) in self.armor_stands.iter_mut().enumerate() {
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
|
||||
for i in 0..self.armor_stands.len() {
|
||||
let at = self.armor_stands[i].position;
|
||||
if !ticking(at.x.floor() as i32, at.z.floor() as i32) {
|
||||
self.armor_stands[i].previous = at;
|
||||
self.armor_stands[i].previous_yaw = self.armor_stands[i].yaw;
|
||||
continue;
|
||||
}
|
||||
self.armor_stand_location_effects(i, false);
|
||||
let a = &mut self.armor_stands[i];
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
|
||||
let (world, registry, facts, collision) = (
|
||||
&self.world,
|
||||
@@ -748,14 +748,31 @@ impl Game<'_> {
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
|
||||
horizon_client::motion::bubble_column_push(
|
||||
world, registry, facts, collision, bounds, velocity,
|
||||
);
|
||||
});
|
||||
if in_water {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
} else {
|
||||
a.enchantment_fall_distance += (a.previous.y - a.position.y).max(0.0);
|
||||
}
|
||||
let landed = a.on_ground && a.enchantment_fall_distance > 0.0;
|
||||
if a.on_ground {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
}
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
}
|
||||
if landed {
|
||||
self.armor_stand_location_effects(i, true);
|
||||
}
|
||||
}
|
||||
for i in dead.into_iter().rev() {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
|
||||
@@ -57,8 +57,8 @@ pub(super) struct Boat {
|
||||
above_bubble_column: bool,
|
||||
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
|
||||
bubble_down: bool,
|
||||
/// A bubble column's drag asked a rider the server can't move itself (a
|
||||
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
|
||||
/// The server asked a rider whose client moves it (a player) to get out:
|
||||
/// `guest_vehicle_tick` honors it (`Entity.ejectPassengers`).
|
||||
pub eject_riders: bool,
|
||||
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
|
||||
tick_count: i32,
|
||||
@@ -101,6 +101,17 @@ pub(super) const BUBBLE_UP_EVENT: i8 = 72;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
/// `AbstractBoat.tick`: passengers lose control after 60 consecutive
|
||||
/// underwater ticks; any other status resets the counter.
|
||||
fn tick_submersion(&mut self) {
|
||||
self.out_of_control =
|
||||
if matches!(self.status, Status::UnderWater | Status::UnderFlowingWater) {
|
||||
self.out_of_control + 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -559,11 +570,7 @@ impl Game<'_> {
|
||||
}
|
||||
b.old_status = b.status;
|
||||
b.status = self.boat_status(&mut b);
|
||||
b.out_of_control = if matches!(b.status, Status::UnderWater | Status::UnderFlowingWater) {
|
||||
b.out_of_control + 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
b.tick_submersion();
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
if !controlled {
|
||||
@@ -751,8 +758,13 @@ impl Game<'_> {
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
// `AbstractBoat.tick` counts submersion on the server even when
|
||||
// its controlling client owns the boat's movement.
|
||||
if driven.contains(&self.boats[i].id) {
|
||||
self.refresh_boat_status(i);
|
||||
}
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_mobs(i, false);
|
||||
self.eject_boat_riders(i);
|
||||
}
|
||||
self.boat_bubble_tick(i);
|
||||
self.boat_contacts(i);
|
||||
@@ -776,6 +788,7 @@ impl Game<'_> {
|
||||
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
|
||||
let status = self.boat_status(&mut probe);
|
||||
self.boats[i].status = status;
|
||||
self.boats[i].tick_submersion();
|
||||
}
|
||||
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
|
||||
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
|
||||
@@ -882,8 +895,8 @@ impl Game<'_> {
|
||||
v.y = 0.6;
|
||||
}
|
||||
}
|
||||
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
|
||||
/// window's player gets out, and a server's player is asked to
|
||||
/// `Entity.ejectPassengers`: the mobs get out here, this window's player
|
||||
/// gets out, and a server's player is asked to
|
||||
/// (`guest_vehicle_tick` lets it out through its client).
|
||||
fn eject_boat_riders(&mut self, i: usize) {
|
||||
self.eject_boat_mobs(i, false);
|
||||
@@ -918,10 +931,6 @@ impl Game<'_> {
|
||||
/// window is its own client.
|
||||
fn boat_bubble_tick(&mut self, i: usize) {
|
||||
let id = self.boats[i].id;
|
||||
// The boat a server's player drives is not ticked here: its status is.
|
||||
if self.guest_vehicles().contains(&id) {
|
||||
self.refresh_boat_status(i);
|
||||
}
|
||||
// An ejection nobody took (its player left the boat first) is void.
|
||||
if !self.guest_rides(id) {
|
||||
self.boats[i].eject_riders = false;
|
||||
|
||||
@@ -1092,9 +1092,12 @@ impl Game<'_> {
|
||||
if self.ui.hide_gui && !focused {
|
||||
return;
|
||||
}
|
||||
self.ui
|
||||
.chat
|
||||
.draw(batch, &self.gui.atlas, self.ticks, focused);
|
||||
self.ui.chat.smooth = self.settings.extras.chat_animation;
|
||||
self.ui.chat.hide_indicator = !self.settings.extras.chat_indicator;
|
||||
// The fractional tick makes the smooth chat extra's slide move every
|
||||
// frame; vanilla's fade reads it too.
|
||||
let now = self.ticks as f32 + self.clock.fraction() as f32;
|
||||
self.ui.chat.draw(batch, &self.gui.atlas, now, focused);
|
||||
}
|
||||
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
|
||||
/// event has one, the entity type and the UUID.
|
||||
|
||||
@@ -183,6 +183,10 @@ impl Game<'_> {
|
||||
self.mod_browser_text(None, true);
|
||||
return Ok(());
|
||||
}
|
||||
Screen::Social => {
|
||||
self.social_backspace();
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -205,6 +209,7 @@ impl Game<'_> {
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::Social => self.social_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
@@ -1052,6 +1057,7 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
@@ -1172,7 +1178,7 @@ impl Game<'_> {
|
||||
"distance": self.settings.horizon.lod_distance,
|
||||
"effective_distance": self.lod_distance(),
|
||||
"terrain_distance": self.terrain_distance(),
|
||||
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else { "local" },
|
||||
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else { "local" },
|
||||
"source_attempted": self.lod_source_attempted,
|
||||
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
|
||||
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
|
||||
|
||||
@@ -771,6 +771,7 @@ impl Game<'_> {
|
||||
thundering: weather && thunder > 0.9,
|
||||
nether: self.dim == Dim::Nether,
|
||||
block_tags: &self.items().block_tags,
|
||||
landing: &self.motion.landing,
|
||||
biomes: world_biomes!(self),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
universal_anger: self.ui.rules.bool("universal_anger"),
|
||||
@@ -1774,6 +1775,40 @@ impl Game<'_> {
|
||||
self.mob_hits_player(amount, from, kind);
|
||||
}
|
||||
MobEvent::ElderCurse { at } => self.elder_curse(at),
|
||||
MobEvent::SetVelocity { target, velocity } => {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
match target {
|
||||
Who::Player => {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.velocity = v;
|
||||
}
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.velocity = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::RemoveKilled { target } => {
|
||||
// The bite that killed it ran `LivingEntity.die` (loot, such
|
||||
// as a magma cube's froglight, and experience) before
|
||||
// `eatEntity` removes it: a removed mob never reaches the
|
||||
// next tick's deaths, so it dies here first.
|
||||
if let Some(m) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == target && !m.alive() && !m.removed)
|
||||
.cloned()
|
||||
{
|
||||
if self.died.insert(target) {
|
||||
self.mob_died(&m);
|
||||
}
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
m.removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::Push { target, velocity } => {
|
||||
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
|
||||
match target {
|
||||
@@ -5730,13 +5765,29 @@ impl Game<'_> {
|
||||
);
|
||||
let (species, variant, color) =
|
||||
(parent.species(), parent.extra.variant, parent.extra.color);
|
||||
let mob_type = parent.kind.clone();
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
|
||||
return false;
|
||||
let id = if species == Species::Piglin {
|
||||
// SpawnEggItem.spawnOffspringFromSpawnEgg: a non-AgeableMob is
|
||||
// constructed, then setBaby(true), without finalizeSpawn. Giving
|
||||
// it adult spawn gear first leaves the baby holding a weapon.
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
self.mobs
|
||||
.push(Mob::new(id, mob_type, Vec3::new(at[0], at[1], at[2]), 0.0));
|
||||
id
|
||||
} else {
|
||||
let Some(id) = self.spawn_mob_spawned(&kind, at) else {
|
||||
return false;
|
||||
};
|
||||
id
|
||||
};
|
||||
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
|
||||
}
|
||||
if species == Species::Piglin {
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
}
|
||||
// `Mob.checkAndHandleImportantInteractions` then calls
|
||||
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
|
||||
// who used the egg (a fox egg used on a block trusts nobody).
|
||||
@@ -5750,17 +5801,49 @@ impl Game<'_> {
|
||||
self.consume_held();
|
||||
true
|
||||
}
|
||||
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
|
||||
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
|
||||
let hit = self.bucket_hit()?;
|
||||
matches!(
|
||||
&*self.block_name(hit.pos),
|
||||
"minecraft:water" | "minecraft:lava"
|
||||
)
|
||||
.then_some(hit)
|
||||
}
|
||||
/// Shared peaceful-mode eligibility for spawn eggs.
|
||||
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
|
||||
let kind = item.trim_end_matches("_spawn_egg");
|
||||
self.ui.difficulty != 0
|
||||
|| self
|
||||
.res
|
||||
.mob_types()
|
||||
.get(kind)
|
||||
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
|
||||
}
|
||||
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
|
||||
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
|
||||
let Some(hit) = self.pick() else {
|
||||
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
|
||||
let Some(hit) = (if fluid {
|
||||
self.spawn_egg_fluid_hit()
|
||||
} else {
|
||||
self.pick()
|
||||
}) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
|
||||
// EntityType.canSpawn: no monsters in peaceful (FAIL ends the click).
|
||||
let id = format!("minecraft:{}", kind.trim_start_matches("minecraft:"));
|
||||
if self.ui.difficulty == 0
|
||||
&& let Some(t) = self.res.mob_types().get(&id)
|
||||
&& !horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
|
||||
if !self.spawn_egg_allowed(&stack.item) {
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
if fluid
|
||||
&& !self.may_build()
|
||||
&& !self.can_place_on_in_adventure([
|
||||
hit.pos[0] - hit.face[0],
|
||||
hit.pos[1] - hit.face[1],
|
||||
hit.pos[2] - hit.face[2],
|
||||
])
|
||||
{
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
|
||||
@@ -5776,11 +5859,15 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
let p = [
|
||||
hit.pos[0] + hit.face[0],
|
||||
hit.pos[1] + hit.face[1],
|
||||
hit.pos[2] + hit.face[2],
|
||||
];
|
||||
let p = if fluid {
|
||||
hit.pos
|
||||
} else {
|
||||
[
|
||||
hit.pos[0] + hit.face[0],
|
||||
hit.pos[1] + hit.face[1],
|
||||
hit.pos[2] + hit.face[2],
|
||||
]
|
||||
};
|
||||
let at = [
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]),
|
||||
@@ -5795,6 +5882,8 @@ impl Game<'_> {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.apply_spawn_egg_entity_data(id, stack);
|
||||
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
@@ -5909,6 +5998,21 @@ impl Game<'_> {
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let scale = self.view_scale();
|
||||
self.rendered_mobs.set(0);
|
||||
// What custom entity model animations read of the frame.
|
||||
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
|
||||
time: self.world.game_time as f64 + alpha,
|
||||
day_time: self.world.day_time as f64 + alpha,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => 0.0,
|
||||
Dim::Nether => -1.0,
|
||||
Dim::End => 1.0,
|
||||
},
|
||||
player: self.player.as_ref().map_or(camera.to_array(), |p| {
|
||||
[p.position.x, p.position.y, p.position.z]
|
||||
}),
|
||||
player_rot: [self.pitch, self.yaw],
|
||||
..Default::default()
|
||||
});
|
||||
for mob in &self.mobs {
|
||||
let entity_start = out.len();
|
||||
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
|
||||
@@ -6793,6 +6897,7 @@ mod pearl_tests {
|
||||
thundering: false,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
|
||||
@@ -990,6 +990,8 @@ impl Game<'_> {
|
||||
let pause_after = props.pause_when_empty_seconds * 20;
|
||||
let idle_timeout = props.player_idle_timeout;
|
||||
let mut next = Instant::now();
|
||||
// The panic that ended the tick loop, when one did.
|
||||
let mut crashed: Option<String> = None;
|
||||
self.fns.permission = Some(props.function_permission_level.clamp(0, 4) as u8);
|
||||
let watchdog = Watchdog::start(props.max_tick_time);
|
||||
let mut braked = Instant::now();
|
||||
@@ -998,10 +1000,18 @@ impl Game<'_> {
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.tick_due(next, self.clock.tick_duration());
|
||||
}
|
||||
while let Ok(line) = state.console.try_recv() {
|
||||
self.console_line(&mut state, &line);
|
||||
// The console's lines and the control socket run in the tick too
|
||||
// (`handleConsoleInputs`): a panic there is the same crash.
|
||||
let prelude = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
while let Ok(line) = state.console.try_recv() {
|
||||
self.console_line(&mut state, &line);
|
||||
}
|
||||
self.poll_server_control(&mut state);
|
||||
}));
|
||||
if let Err(payload) = prelude {
|
||||
crashed = Some(panic_message(payload.as_ref()));
|
||||
break;
|
||||
}
|
||||
self.poll_server_control(&mut state);
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
if pause_after > 0 {
|
||||
// A `/pregen` task keeps the server busy as a player would:
|
||||
@@ -1029,11 +1039,23 @@ impl Game<'_> {
|
||||
self.ticks = self.ticks.max(1);
|
||||
}
|
||||
// MinecraftServer.tickServer: while paused only the connections run.
|
||||
match &mut state.report {
|
||||
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
|
||||
recording.add(wall, cpu, spans)
|
||||
})?,
|
||||
None => self.tick_server_timed(paused)?,
|
||||
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
|
||||
// report and still stops the server (saves). A panic here does the same.
|
||||
let ticked =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
match &mut state.report {
|
||||
Some(recording) => self.tick_server_spans(paused, |wall, cpu, spans| {
|
||||
recording.add(wall, cpu, spans)
|
||||
}),
|
||||
None => self.tick_server_timed(paused),
|
||||
}
|
||||
}));
|
||||
match ticked {
|
||||
Ok(result) => result?,
|
||||
Err(payload) => {
|
||||
crashed = Some(panic_message(payload.as_ref()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if state.report.as_ref().is_some_and(report::Recording::done)
|
||||
&& let Some(recording) = state.report.take()
|
||||
@@ -1067,7 +1089,10 @@ impl Game<'_> {
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
self.stop_dedicated()
|
||||
match crashed {
|
||||
Some(message) => self.stop_after_crash(&message),
|
||||
None => self.stop_dedicated(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A dedicated server of its own, not single player's server thread
|
||||
@@ -1237,6 +1262,7 @@ impl Game<'_> {
|
||||
self.console_command(state, line);
|
||||
self.console_broadcast = None;
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
crash_fixture("hub", || host.hub.guests.iter().any(|g| g.placed));
|
||||
self.run_level_teleports(&mut host);
|
||||
self.send_admin_feedback(&mut host, None);
|
||||
self.lan = Some(host);
|
||||
@@ -1818,6 +1844,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `MinecraftServer.runServer`'s `finally`: a tick that panicked still saves. What the panic
|
||||
/// interrupted is not trusted: a guest's turn (`as_guest`) holds the game's player state
|
||||
/// swapped in, so its record is older than what the game holds, and writing it would put
|
||||
/// back older data than the last autosave; that guest keeps the file it has. A hub that the
|
||||
/// panic took with it (`self.lan.take()` was in progress) saves no players at all. The
|
||||
/// levels, the scoreboard and the other state are saved as in a normal stop.
|
||||
fn stop_after_crash(&mut self, message: &str) -> Result<()> {
|
||||
log("ERROR", &format!("The server thread panicked: {message}"));
|
||||
let acting = self.lan_acting;
|
||||
let hub_lost = self.lan.is_none();
|
||||
if let (Some(conn), Some(host)) = (acting, self.lan.as_mut())
|
||||
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
{
|
||||
guest.placed = false;
|
||||
}
|
||||
if hub_lost {
|
||||
log(
|
||||
"WARN",
|
||||
"The panic interrupted the network tick: players are not saved, only the world",
|
||||
);
|
||||
} else if acting.is_some() {
|
||||
log(
|
||||
"WARN",
|
||||
"The panic interrupted a player's turn: that player keeps the last saved file",
|
||||
);
|
||||
}
|
||||
self.native_lifecycle.begin_close();
|
||||
log("INFO", "Saving worlds");
|
||||
self.save_game()?;
|
||||
self.log_saved_levels();
|
||||
self.native_lifecycle_world_closed();
|
||||
self.lan_close(false);
|
||||
Err(format!("the server thread panicked: {message}").into())
|
||||
}
|
||||
|
||||
fn stop_dedicated(&mut self) -> Result<()> {
|
||||
// The close request starts before player and world saves.
|
||||
self.native_lifecycle.begin_close();
|
||||
@@ -1855,6 +1916,28 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `tools/test_dedicated_crash_save.py`'s fixture: with `HORIZON_CRASH_FIXTURE` naming `point`
|
||||
/// (`guest`: during a placed guest's turn, `hub`: while a console line has the hub out and a
|
||||
/// player is in), the tick panics there, to see what `stop_after_crash` keeps.
|
||||
/// `armed` is asked only when the variable names `point`, so the tick pays one
|
||||
/// load of a `OnceLock` without it.
|
||||
pub(super) fn crash_fixture(point: &str, armed: impl FnOnce() -> bool) {
|
||||
static FIXTURE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
let fixture = FIXTURE.get_or_init(|| std::env::var("HORIZON_CRASH_FIXTURE").ok());
|
||||
if fixture.as_deref() == Some(point) && armed() {
|
||||
panic!("crash fixture at {point}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a panic payload (`panic!("..")` is a `&str`, a formatted one a `String`).
|
||||
fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| (*s).to_owned())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown panic".to_owned())
|
||||
}
|
||||
|
||||
/// The width and height of a PNG (its IHDR chunk).
|
||||
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
|
||||
@@ -1867,7 +1950,7 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{capture_log, log, log_from};
|
||||
use super::{capture_log, log, log_from, panic_message};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
@@ -1875,6 +1958,19 @@ mod tests {
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// A `panic!("literal")` carries a `&str`, a formatted one a `String`; anything else is
|
||||
/// named as unknown, so the crash line is never empty.
|
||||
#[test]
|
||||
fn a_panic_payload_is_read_as_text() {
|
||||
let literal = std::panic::catch_unwind(|| panic!("a literal")).unwrap_err();
|
||||
assert_eq!(panic_message(literal.as_ref()), "a literal");
|
||||
let n = 3;
|
||||
let formatted = std::panic::catch_unwind(|| panic!("tick {n}")).unwrap_err();
|
||||
assert_eq!(panic_message(formatted.as_ref()), "tick 3");
|
||||
let other = std::panic::catch_unwind(|| std::panic::panic_any(7_u8)).unwrap_err();
|
||||
assert_eq!(panic_message(other.as_ref()), "unknown panic");
|
||||
}
|
||||
|
||||
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
|
||||
const WARNING: &str = "Authentication warning";
|
||||
const READY: &str = "horizon-console-log-ready\n";
|
||||
|
||||
@@ -790,40 +790,73 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
let mut disks = Vec::new();
|
||||
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
|
||||
let Some(stack) = stack else { continue };
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = self.stations().enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if !def
|
||||
.slots
|
||||
.iter()
|
||||
.any(|s| s == slot || s == "armor" || s == "any")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:location_changed"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["effect"]["type"] == "minecraft:replace_disk"
|
||||
&& location_requirements(&effect["requirements"], on_ground, riding)
|
||||
{
|
||||
disks.push((effect["effect"].clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let disks = location_disks(
|
||||
&self.stations().enchantments,
|
||||
self.armor.iter().zip(["head", "chest", "legs", "feet"]),
|
||||
on_ground,
|
||||
riding,
|
||||
);
|
||||
for (effect, level) in disks {
|
||||
self.replace_disk(&effect, level, at);
|
||||
self.replace_disk(&effect, level, at, Who::Player);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.onChangedBlock`, landing and `collectEquipmentChanges`
|
||||
/// for an armor stand: run location effects before travel (including a
|
||||
/// piston push), on landing, and for newly equipped items.
|
||||
pub(super) fn armor_stand_location_effects(&mut self, index: usize, landed: bool) {
|
||||
let stand = &mut self.armor_stands[index];
|
||||
let block = BlockPos::containing(stand.position);
|
||||
let moved = !landed && stand.enchantment_block.replace(block) != Some(block);
|
||||
let changed: [bool; 6] =
|
||||
std::array::from_fn(|i| stand.equipment[i] != stand.enchantment_equipment[i]);
|
||||
if !moved && !landed && !changed.iter().any(|c| *c) {
|
||||
return;
|
||||
}
|
||||
stand.enchantment_equipment.clone_from(&stand.equipment);
|
||||
let stand = &self.armor_stands[index];
|
||||
let at = [stand.position.x, stand.position.y, stand.position.z];
|
||||
let who = Who::Mob(stand.id);
|
||||
let equipment = stand
|
||||
.equipment
|
||||
.iter()
|
||||
.zip(horizon_client::entities::ArmorStand::SLOTS)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| moved || landed || changed[*i])
|
||||
.filter(|(_, (stack, _))| {
|
||||
moved
|
||||
|| landed
|
||||
|| !stack.as_ref().is_some_and(|stack| {
|
||||
let data = stack.data();
|
||||
let component = |id| data.and_then(|data| data.other(id));
|
||||
let max_damage = match component("minecraft:max_damage") {
|
||||
Some(horizon_world::nbt::Tag::Int(value)) => *value as u32,
|
||||
_ => self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage),
|
||||
};
|
||||
max_damage > 0
|
||||
&& component("minecraft:unbreakable").is_none()
|
||||
&& component("!minecraft:max_damage").is_none()
|
||||
&& component("!minecraft:damage").is_none()
|
||||
&& stack.damage >= max_damage
|
||||
})
|
||||
})
|
||||
.map(|(_, item)| item);
|
||||
let disks = location_disks(
|
||||
&self.stations().enchantments,
|
||||
equipment,
|
||||
stand.on_ground,
|
||||
stand.vehicle.is_some(),
|
||||
);
|
||||
for (effect, level) in disks {
|
||||
self.replace_disk(&effect, level, at, who);
|
||||
}
|
||||
}
|
||||
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
|
||||
/// predicate become the effect's state, and each placement is a game event.
|
||||
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
|
||||
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3], who: Who) {
|
||||
let registry = self.pack.assets.registry;
|
||||
let spec = &effect["block_state"];
|
||||
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
|
||||
@@ -861,7 +894,7 @@ impl Game<'_> {
|
||||
self.post_block_game_event(
|
||||
"minecraft:block_place",
|
||||
pos,
|
||||
Some(Who::Player),
|
||||
Some(who),
|
||||
Some(new),
|
||||
);
|
||||
}
|
||||
@@ -966,6 +999,44 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EnchantmentHelper.runLocationChangedEffects`: the replace-disk effects of
|
||||
/// active equipment slots, with the wearer's ground/passenger requirements.
|
||||
fn location_disks<'a>(
|
||||
enchantments: &Enchantments,
|
||||
equipment: impl IntoIterator<Item = (&'a Option<ItemStack>, &'static str)>,
|
||||
on_ground: bool,
|
||||
riding: bool,
|
||||
) -> Vec<(Value, u32)> {
|
||||
let mut disks = Vec::new();
|
||||
for (stack, slot) in equipment {
|
||||
let Some(stack) = stack else { continue };
|
||||
for (id, level) in stack.enchantments() {
|
||||
let Some(def) = enchantments.get(id) else {
|
||||
continue;
|
||||
};
|
||||
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
|
||||
let hand = matches!(slot, "mainhand" | "offhand");
|
||||
if !def.slots.iter().any(|s| {
|
||||
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:location_changed"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if effect["effect"]["type"] == "minecraft:replace_disk"
|
||||
&& location_requirements(&effect["requirements"], on_ground, riding)
|
||||
{
|
||||
disks.push((effect["effect"].clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
disks
|
||||
}
|
||||
|
||||
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
|
||||
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
|
||||
fn soul_speed_modifiers(
|
||||
|
||||
@@ -25,7 +25,7 @@ impl Game<'_> {
|
||||
let at = camera.to_array().map(|v| v.floor() as i32);
|
||||
self.entity_culler = (self.settings.horizon.entity_culling
|
||||
// Packs can replace an opaque block's model or texture.
|
||||
&& self.settings.resource_packs.iter().all(|id| id == "vanilla")
|
||||
&& self.model_packs_are_vanilla()
|
||||
&& self.player.is_some()
|
||||
&& !self.spectator()
|
||||
&& !opaque(self.res.facts().get(self.world.block(at[0], at[1], at[2]))))
|
||||
|
||||
@@ -698,6 +698,74 @@ impl Game<'_> {
|
||||
c.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
|
||||
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
|
||||
// does nothing while it is in the ground; `hurtServer` never hurts them.
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
for p in projectiles.iter_mut().filter(|p| !p.stuck) {
|
||||
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
|
||||
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let eye_height = p.kind.eye_height();
|
||||
let dir = direction([p.position.x, p.position.y + eye_height, p.position.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
p.velocity = Vec3::new(
|
||||
p.velocity.x + dir[0] * power,
|
||||
p.velocity.y + dir[1] * power,
|
||||
p.velocity.z + dir[2] * power,
|
||||
);
|
||||
p.needs_sync = true;
|
||||
}
|
||||
self.creatures.projectiles = projectiles;
|
||||
// Firework rockets (0.25 wide, so 0.2125 to the eyes).
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in &mut rockets {
|
||||
let feet = r.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet.x - 0.125, feet.y, feet.z - 0.125],
|
||||
[feet.x + 0.125, feet.y + 0.25, feet.z + 0.125],
|
||||
);
|
||||
let dir = direction([feet.x, feet.y + 0.2125, feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
r.velocity = Vec3::new(
|
||||
r.velocity.x + dir[0] * power,
|
||||
r.velocity.y + dir[1] * power,
|
||||
r.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
// Thrown tridents live apart from the other projectiles (`game/trident.rs`): 0.5
|
||||
// wide and high, eyes at 0.13 (`EntityType.TRIDENT`), and like any arrow
|
||||
// (`AbstractArrow.push`) not pushed while in the ground.
|
||||
let mut tridents = std::mem::take(&mut self.tridents);
|
||||
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
|
||||
let feet = t.position;
|
||||
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
|
||||
if dist > 1.0 {
|
||||
continue;
|
||||
}
|
||||
let exposure = self.seen_percent(
|
||||
center,
|
||||
[feet.x - 0.25, feet.y, feet.z - 0.25],
|
||||
[feet.x + 0.25, feet.y + 0.5, feet.z + 0.25],
|
||||
);
|
||||
let dir = direction([feet.x, feet.y + f64::from(0.13_f32), feet.z]);
|
||||
let power = (1.0 - dist) * f64::from(exposure);
|
||||
t.velocity = Vec3::new(
|
||||
t.velocity.x + dir[0] * power,
|
||||
t.velocity.y + dir[1] * power,
|
||||
t.velocity.z + dir[2] * power,
|
||||
);
|
||||
}
|
||||
self.tridents = tridents;
|
||||
// Frames, paintings, cushions and armour stands last: vanilla lists
|
||||
// the entities in reach before hurting any, so the items they drop
|
||||
// here are neither destroyed nor pushed by the same blast.
|
||||
|
||||
@@ -62,6 +62,7 @@ impl Game<'_> {
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
self.native_frame(elapsed);
|
||||
self.recipes_received_now();
|
||||
}
|
||||
/// The frame counter for mods: fps, min, 1% low.
|
||||
pub(super) fn frame_reading(&self) -> (u32, u32, u32) {
|
||||
|
||||
@@ -580,6 +580,12 @@ impl Game<'_> {
|
||||
.map(|i| i.id)
|
||||
});
|
||||
if let Some(id) = hit {
|
||||
// Projectile.onHit on an entity: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
[h.position.x, h.position.y, h.position.z],
|
||||
);
|
||||
h.state = State::Hooked(id);
|
||||
h.velocity = Vec3::ZERO;
|
||||
self.hook = Some(h);
|
||||
@@ -605,6 +611,12 @@ impl Game<'_> {
|
||||
shape.clip(h.position, to, p)
|
||||
});
|
||||
if let Some(hit) = hit.ok().flatten() {
|
||||
// Projectile.onHit on a block: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
[hit.location.x, hit.location.y, hit.location.z],
|
||||
);
|
||||
h.velocity = rebound(h.velocity, hit.location, h.position);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,25 @@ impl Game<'_> {
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// `EntityGetter.getEntityCollisions` for a dropped item: the entities whose
|
||||
/// `canBeCollidedWith(item)` is true. A boat and a minecart always, a living shulker,
|
||||
/// and a happy ghast only while it is held still (`isOnStillTimeout`): a player on its
|
||||
/// back keeps it still, so an item dropped there stays on top.
|
||||
pub(super) fn item_entity_collisions(&self) -> Vec<Aabb> {
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
(m.species() == horizon_client::mobs::Species::HappyGhast
|
||||
&& m.alive()
|
||||
&& !m.is_baby()
|
||||
&& happy_ghast::on_still_timeout(m))
|
||||
|| (m.species() == horizon_client::mobs::Species::Shulker && m.alive())
|
||||
})
|
||||
.map(|m| m.bounds())
|
||||
.chain(self.boats.iter().map(super::boats::Boat::bounds))
|
||||
.chain(self.carts.iter().map(|c| c.bounds()))
|
||||
.collect()
|
||||
}
|
||||
/// `HappyGhast.mobInteract`. Returns whether the click was used.
|
||||
pub(super) fn interact_happy_ghast(&mut self, index: usize, item: &str) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
|
||||
@@ -21,7 +21,7 @@ impl Game<'_> {
|
||||
return true;
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat | Screen::CommandBlock => true,
|
||||
Screen::Chat | Screen::CommandBlock | Screen::Social => true,
|
||||
Screen::DebugOptions => self
|
||||
.debug_options
|
||||
.as_ref()
|
||||
@@ -117,6 +117,7 @@ impl Game<'_> {
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(&text),
|
||||
Screen::CommandBlock => self.command_block_type(&text),
|
||||
Screen::Social => self.social_type(&text),
|
||||
Screen::DebugOptions => self.debug_options_type(&text),
|
||||
Screen::WorldOptions => self.world_options_text(Some(&text)),
|
||||
Screen::Creative => self.creative_search_edit(Some(&text)),
|
||||
|
||||
@@ -32,8 +32,37 @@ use horizon_protocol::{
|
||||
text::literal,
|
||||
};
|
||||
use horizon_world::entity_move::EntityMoveWorld;
|
||||
use std::collections::HashMap;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
|
||||
/// changes as `(pos, previous, viewers)`, in position order. A container that
|
||||
/// is no longer counted goes to 0 and leaves `known`.
|
||||
pub(super) fn viewer_changes(
|
||||
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
|
||||
dim: &'static str,
|
||||
mut now: HashMap<(i32, i32, i32), u32>,
|
||||
) -> Vec<((i32, i32, i32), u32, u32)> {
|
||||
let mut changes = Vec::new();
|
||||
known.retain(|&(d, pos), previous| {
|
||||
if d != dim {
|
||||
return true;
|
||||
}
|
||||
let viewers = now.remove(&pos).unwrap_or(0);
|
||||
if viewers != *previous {
|
||||
changes.push((pos, *previous, viewers));
|
||||
*previous = viewers;
|
||||
}
|
||||
viewers > 0
|
||||
});
|
||||
for (pos, viewers) in now {
|
||||
known.insert((dim, pos), viewers);
|
||||
changes.push((pos, 0, viewers));
|
||||
}
|
||||
changes.sort_unstable();
|
||||
changes
|
||||
}
|
||||
|
||||
/// Per-guest state of the gameplay code that lives outside `Guest`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct GuestPlay {
|
||||
@@ -297,6 +326,9 @@ impl Game<'_> {
|
||||
f: impl FnOnce(&mut Self) -> R,
|
||||
) -> Option<R> {
|
||||
let swap = self.enter_guest(host, conn)?;
|
||||
super::dedicated::crash_fixture("guest", || {
|
||||
host.hub.guests.iter().any(|g| g.conn == conn && g.placed)
|
||||
});
|
||||
let dimension =
|
||||
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
|
||||
let result = horizon_client::block_contact::in_level(dimension, || f(self));
|
||||
@@ -1363,6 +1395,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerOpenersCounter` for the mod containers of the level in place:
|
||||
/// the players with each one open, recounted from their menus, so a
|
||||
/// change is heard whichever way a player left (`native_mod_container_viewers`).
|
||||
fn tick_mod_container_viewers(&mut self, host: &LanHost) {
|
||||
let mut now: HashMap<(i32, i32, i32), u32> = HashMap::new();
|
||||
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
|
||||
// `BarrelBlockEntity.startOpen`: a spectator does not open it.
|
||||
if !g.spectator()
|
||||
&& let Some(m) = &g.menu
|
||||
&& let Slots::Block(pos) = m.slots
|
||||
&& self.mod_menu_at(pos).is_some()
|
||||
{
|
||||
*now.entry(pos).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let dim = self.dim.id();
|
||||
for (pos, previous, viewers) in viewer_changes(&mut self.mod_container_viewers, dim, now) {
|
||||
self.native_mod_container_viewers(dim, [pos.0, pos.1, pos.2], viewers, previous);
|
||||
}
|
||||
}
|
||||
|
||||
/// `stillValid` of the guest's open container: its blocks are still there
|
||||
/// and in reach, or its vehicle exists and is in reach. True with none open.
|
||||
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
|
||||
@@ -1869,6 +1922,23 @@ impl Game<'_> {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (p.yaw, p.pitch);
|
||||
// SpawnEggItem.use calls ServerLevel.mayInteract at its own
|
||||
// fluid ray's position, unlike useOn's packet-supplied block.
|
||||
let held = if hand == Hand::Off {
|
||||
g.offhand.as_ref()
|
||||
} else {
|
||||
g.inventory.get(g.selected).and_then(Option::as_ref)
|
||||
};
|
||||
if held.is_some_and(|s| s.item.ends_with("_spawn_egg")) {
|
||||
let hit = self
|
||||
.as_guest(host, conn, |g| {
|
||||
g.with_hand(hand, |g| g.spawn_egg_fluid_hit())
|
||||
})
|
||||
.flatten();
|
||||
if hit.is_some_and(|h| self.under_spawn_protection(host, gi, h.pos)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
|
||||
let used = self.as_guest(host, conn, |g| {
|
||||
// A horn's sound, inline instruments included.
|
||||
@@ -3235,6 +3305,7 @@ impl Game<'_> {
|
||||
self.lan_stations_tick(host);
|
||||
self.lan_tables_tick(host);
|
||||
self.lan_containers_tick(host);
|
||||
self.tick_mod_container_viewers(host);
|
||||
self.tick_lan_mount_menus(host);
|
||||
self.announce_guest_advancements(host);
|
||||
let conns: Vec<u32> = host
|
||||
|
||||
@@ -133,8 +133,9 @@ impl Game<'_> {
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
// AbstractBoat.tickBubbleColumn: a down column ejects the rider.
|
||||
ejected = std::mem::take(&mut b.eject_riders);
|
||||
// `Entity.ejectPassengers`: every player in the boat must see
|
||||
// the request; the common boat tick clears it once nobody rides.
|
||||
ejected = b.eject_riders;
|
||||
}
|
||||
// ServerGamePacketListenerImpl.tick: the vehicle's last good position
|
||||
// is where it is now, so a move the server made (a piston, a push)
|
||||
|
||||
@@ -549,6 +549,8 @@ impl Game<'_> {
|
||||
.is_some_and(|p| !p.streaming.forced.is_empty())
|
||||
};
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
// A viewer count not yet told to 0 (`tick_mod_container_viewers`).
|
||||
|| self.mod_container_viewers.keys().any(|(k, _)| *k == d.id())
|
||||
|| self.player_bed_levels().any(|b| b == *d)
|
||||
|| forced
|
||||
// A game test runs in its own level, players or not.
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
use super::{Game, commands::Target};
|
||||
use horizon_client::advancements::EntityView;
|
||||
use horizon_client::mobs::{Damage, DamageKind, MobEvent, SpawnSetup, Species};
|
||||
use horizon_core::{position::BlockPos, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
|
||||
pub(super) struct Bolt {
|
||||
/// The entity id a server's players know it by (0 until it has one).
|
||||
@@ -452,10 +456,26 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `thunderHit` for entities within 3 blocks (up to 9 above). Returns the
|
||||
/// mobs in that box before the hit, as `CHANNELED_LIGHTNING` counts them.
|
||||
/// `thunderHit` for entities whose box meets the strike box, 3 blocks
|
||||
/// around and up to 9 above (`getEntities(this, AABB)` tests boxes, not
|
||||
/// positions). Returns the mobs in that box before the hit, as
|
||||
/// `CHANNELED_LIGHTNING` counts them. Boats, minecarts and experience
|
||||
/// orbs are hit too (`VehicleEntity.hurtServer` adds 5 x 10 > 40 and
|
||||
/// destroys them; an orb has 5 health).
|
||||
fn thunder_hit(&mut self, b: &mut Bolt) -> Vec<EntityView> {
|
||||
let at = b.at;
|
||||
let strike = Aabb::new(
|
||||
Vec3::new(at[0] - 3.0, at[1] - 3.0, at[2] - 3.0),
|
||||
Vec3::new(at[0] + 3.0, at[1] + 9.0, at[2] + 3.0),
|
||||
);
|
||||
// Whether the box of an entity standing at `p` (half width `w`,
|
||||
// height `h`) meets the strike box.
|
||||
let meets = move |p: [f64; 3], w: f64, h: f64| {
|
||||
strike.intersects(Aabb::new(
|
||||
Vec3::new(p[0] - w, p[1], p[2] - w),
|
||||
Vec3::new(p[0] + w, p[1] + h, p[2] + w),
|
||||
))
|
||||
};
|
||||
let inside = move |p: [f64; 3]| {
|
||||
(p[0] - at[0]).abs() <= 3.0
|
||||
&& (p[2] - at[2]).abs() <= 3.0
|
||||
@@ -465,7 +485,14 @@ impl Game<'_> {
|
||||
let struck: Vec<(u32, EntityView)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive() && inside([m.position.x, m.position.y, m.position.z]))
|
||||
.filter(|m| {
|
||||
m.alive()
|
||||
&& meets(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.width / 2.0,
|
||||
m.kind.height,
|
||||
)
|
||||
})
|
||||
.map(|m| (m.id, self.mob_entity(m)))
|
||||
.collect();
|
||||
b.hit.extend(struck.iter().map(|(id, _)| *id));
|
||||
@@ -477,7 +504,11 @@ impl Game<'_> {
|
||||
let mut events = Vec::new();
|
||||
let peaceful = self.ui.difficulty == 0;
|
||||
for m in self.mobs.iter_mut().filter(|m| m.alive()) {
|
||||
if !inside([m.position.x, m.position.y, m.position.z]) {
|
||||
if !meets(
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.kind.width / 2.0,
|
||||
m.kind.height,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let setup = |m: &horizon_client::mobs::Mob| SpawnSetup {
|
||||
@@ -554,10 +585,34 @@ impl Game<'_> {
|
||||
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
|
||||
}
|
||||
}
|
||||
// Every player in the box, the server's players too.
|
||||
let strike = move |g: &mut Self| {
|
||||
// Boats, then minecarts: `VehicleEntity.hurtServer` for 5 damage
|
||||
// (x 10 over the 40 a vehicle takes) destroys them. Lightning is no
|
||||
// fire source, so a TNT cart does not prime.
|
||||
let mut i = self.boats.len();
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let bb = self.boats[i].bounds();
|
||||
if strike.intersects(bb) {
|
||||
self.hurt_boat(i, 5.0, false);
|
||||
}
|
||||
}
|
||||
let mut i = self.carts.len();
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let bb = self.carts[i].bounds();
|
||||
if strike.intersects(bb) {
|
||||
self.hurt_cart(i, 5.0, false, false);
|
||||
}
|
||||
}
|
||||
// `ExperienceOrb.hurtServer`: 5 damage off 5 health.
|
||||
let orbs_before = self.orb_ids();
|
||||
self.orbs
|
||||
.retain_mut(|o| !(strike.intersects(o.bounds()) && o.hurt(5.0)));
|
||||
self.native_orbs_left_since(&orbs_before);
|
||||
// Every player in the box, the server's players too (0.6 x 1.8).
|
||||
let strike_players = move |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_ref().map(|p| p.position)
|
||||
&& inside([p.x, p.y, p.z])
|
||||
&& meets([p.x, p.y, p.z], 0.3, 1.8)
|
||||
{
|
||||
if let Some(p) = g.player.as_mut() {
|
||||
p.fire_ticks += 1;
|
||||
@@ -570,18 +625,18 @@ impl Game<'_> {
|
||||
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
|
||||
}
|
||||
};
|
||||
strike(self);
|
||||
self.each_guest_hit(strike);
|
||||
strike_players(self);
|
||||
self.each_guest_hit(strike_players);
|
||||
b.hit_items.extend(
|
||||
self.dropped
|
||||
.iter()
|
||||
.filter(|e| inside([e.position.x, e.position.y, e.position.z]))
|
||||
.filter(|e| meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25))
|
||||
.map(|e| e.id),
|
||||
);
|
||||
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
|
||||
let before = self.dropped_ids();
|
||||
self.dropped.retain(|e| {
|
||||
!inside([e.position.x, e.position.y, e.position.z])
|
||||
!meets([e.position.x, e.position.y, e.position.z], 0.125, 0.25)
|
||||
|| e.stack.item.contains("netherite")
|
||||
|| (e.age < 20 && b.spared.contains(&e.id))
|
||||
});
|
||||
|
||||
@@ -414,13 +414,68 @@ impl Game<'_> {
|
||||
}
|
||||
self.rail_pos(x, y, z)
|
||||
}
|
||||
/// `AbstractMinecart.canCollideWith`, for `Entity.collide`'s `getEntityCollisions`:
|
||||
/// `AbstractBoat.canVehicleCollide` takes any other cart, boat, shulker or pushable mob and
|
||||
/// player, except one riding this cart. A cart that runs into one has a horizontal
|
||||
/// collision, which sets a TNT minecart off.
|
||||
fn cart_entity_collisions(&self, c: &Minecart) -> Vec<Aabb> {
|
||||
let near = c.bounds().inflate(Vec3::new(2.0, 2.0, 2.0));
|
||||
let mut boxes: Vec<Aabb> = self
|
||||
.carts
|
||||
.iter()
|
||||
.filter(|o| o.id != c.id)
|
||||
.map(Minecart::bounds)
|
||||
.chain(self.boats.iter().map(super::boats::Boat::bounds))
|
||||
.chain(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| {
|
||||
!m.removed
|
||||
&& m.extra.mount != Some(c.id)
|
||||
&& (m.pushable()
|
||||
|| m.species() == horizon_client::mobs::Species::Shulker
|
||||
&& m.alive())
|
||||
})
|
||||
.map(|m| m.bounds()),
|
||||
)
|
||||
.collect();
|
||||
if self.riding != Some(c.id)
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
&& !self.spectator()
|
||||
{
|
||||
boxes.push(p.bounds());
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
boxes.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
host.hub.here(g)
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& g.vehicle != Some(c.id)
|
||||
})
|
||||
.map(|g| g.bounds()),
|
||||
);
|
||||
}
|
||||
boxes.retain(|b| b.intersects(near));
|
||||
boxes
|
||||
}
|
||||
/// Entity.move for a cart: collisions zero the blocked components.
|
||||
fn move_cart(&self, c: &mut Minecart, delta: Vec3) {
|
||||
let mut scene = SceneCollisions {
|
||||
let scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let entities = self.cart_entity_collisions(c);
|
||||
let mut scene = horizon_client::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &entities,
|
||||
border: None,
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) {
|
||||
c.position = Vec3::new(
|
||||
c.position.x + moved.x,
|
||||
|
||||
@@ -185,6 +185,12 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
|
||||
const BUILD_INFO: &str = "native-mods.json";
|
||||
|
||||
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
|
||||
fn built_with_mods(built: &str) -> bool {
|
||||
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
|
||||
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
|
||||
}
|
||||
|
||||
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
|
||||
/// built with (`built`: its [`BUILD_INFO`], empty for a release or Nightly) and that has no row
|
||||
/// yet. The page said "No mods" before, and players copied the files into `horizon-mods` (#4770).
|
||||
@@ -272,6 +278,15 @@ impl Game<'_> {
|
||||
.ok()
|
||||
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
|
||||
.unwrap_or_default();
|
||||
horizon_client::gfx::native_render::set_mods_built_in(
|
||||
built_with_mods(&built)
|
||||
|| self
|
||||
.settings
|
||||
.mods
|
||||
.installed
|
||||
.iter()
|
||||
.any(|m| m.native.is_some()),
|
||||
);
|
||||
let native_dir = Self::game_dir().join(mod_list::NATIVE_DIR);
|
||||
let unbuilt = not_built(&native_dir, &built, &self.settings.mods.installed);
|
||||
self.settings.mods.installed.extend(unbuilt);
|
||||
|
||||
@@ -14,7 +14,12 @@ impl Game<'_> {
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
let (music, volume) = self.situational_music();
|
||||
let (mut music, mut volume) = self.situational_music();
|
||||
// A native mod's own choice of track and volume, then its layers.
|
||||
self.native_music(&mut music, &mut volume);
|
||||
let mut layers = Vec::new();
|
||||
self.native_music_layers(&mut layers);
|
||||
self.music_layers.tick(&layers, &mut self.audio);
|
||||
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
|
||||
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
|
||||
if frequency != self.music.frequency {
|
||||
|
||||
@@ -260,7 +260,7 @@ impl Game<'_> {
|
||||
Screen::Chat if self.title.is_none() => true,
|
||||
Screen::Options => self.ui.options.typing(),
|
||||
Screen::WorldOptions => self.world_options_typing(),
|
||||
Screen::CommandBlock => true,
|
||||
Screen::CommandBlock | Screen::Social => true,
|
||||
Screen::Inventory => self.station_typing() || self.mod_browser_typing(),
|
||||
_ => self
|
||||
.title
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
//! A client mod's view and its own items (docs/native-mods.md, "A client mod's view and items"): the
|
||||
//! first block along the view, across the distant terrain past the loaded chunks, and items whose
|
||||
//! right-click is the mod's alone, heard as use events the mod polls.
|
||||
use super::Game;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A stack as a client mod's use events carry it.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeHeldItem {
|
||||
pub(crate) id: String,
|
||||
pub(crate) count: u32,
|
||||
/// The inventory slot: 0 to 8, the hotbar (a mod use is the main hand's).
|
||||
pub(crate) slot: u32,
|
||||
/// `minecraft:custom_name` as SNBT, when the stack has one.
|
||||
pub(crate) custom_name: Option<String>,
|
||||
/// `minecraft:custom_data` as SNBT, when the stack has some.
|
||||
pub(crate) custom_data: Option<String>,
|
||||
}
|
||||
/// The block a ray along the view meets.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeHit {
|
||||
pub(crate) block_x: i32,
|
||||
pub(crate) block_y: i32,
|
||||
pub(crate) block_z: i32,
|
||||
/// Where the ray meets the block.
|
||||
pub(crate) x: f64,
|
||||
pub(crate) y: f64,
|
||||
pub(crate) z: f64,
|
||||
/// The block's id (`minecraft:stone`).
|
||||
pub(crate) block: String,
|
||||
}
|
||||
/// What happened to a mod's claimed item, oldest first.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum NativeModEvent {
|
||||
/// A right-click with the item began its use: the stack, and 0 ticks.
|
||||
UseStart(NativeHeldItem, u32),
|
||||
/// The use ended: the stack it began with, and the ticks it was held. It
|
||||
/// ends when the button comes up, the hand's item changes or leaves the
|
||||
/// claim, the player respawns, or the game joins a server.
|
||||
UseStop(NativeHeldItem, u32),
|
||||
}
|
||||
/// The farthest a ray along the view reaches, in blocks.
|
||||
pub(crate) const RAYCAST_MAX: f32 = 1024.0;
|
||||
/// The ids a mod may claim.
|
||||
const CLAIMS_MAX: usize = 64;
|
||||
/// Events kept for a mod between two polls.
|
||||
const EVENTS_MAX: usize = 256;
|
||||
|
||||
/// A claimed item's use under way.
|
||||
struct ModUse {
|
||||
mod_id: String,
|
||||
item: NativeHeldItem,
|
||||
/// The player's `using` while it is this use: its slot and ticks.
|
||||
slot: usize,
|
||||
ticks: u32,
|
||||
}
|
||||
|
||||
/// The items client mods claim, their events waiting for a poll, and the use under way.
|
||||
#[derive(Default)]
|
||||
pub(super) struct ModInput {
|
||||
claims: BTreeMap<String, Vec<String>>,
|
||||
events: BTreeMap<String, Vec<NativeModEvent>>,
|
||||
using: Option<ModUse>,
|
||||
}
|
||||
|
||||
impl ModInput {
|
||||
fn claim(&mut self, mod_id: &str, ids: &[String]) {
|
||||
let ids = ids.iter().take(CLAIMS_MAX).cloned().collect();
|
||||
self.claims.insert(mod_id.to_owned(), ids);
|
||||
}
|
||||
/// The mod that claims `item`, the first in id order.
|
||||
pub(super) fn claimant(&self, item: &str) -> Option<&str> {
|
||||
self.claims
|
||||
.iter()
|
||||
.find(|(_, ids)| ids.iter().any(|i| i == item))
|
||||
.map(|(id, _)| id.as_str())
|
||||
}
|
||||
fn push(&mut self, mod_id: &str, event: NativeModEvent) {
|
||||
let events = self.events.entry(mod_id.to_owned()).or_default();
|
||||
if events.len() < EVENTS_MAX {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
fn poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
|
||||
self.events.remove(mod_id).unwrap_or_default()
|
||||
}
|
||||
/// Mod `mod_id`'s use of `item` in `slot` begins, ending any other first.
|
||||
fn start(&mut self, mod_id: &str, item: NativeHeldItem, slot: usize) {
|
||||
self.end();
|
||||
self.push(mod_id, NativeModEvent::UseStart(item.clone(), 0));
|
||||
self.using = Some(ModUse {
|
||||
mod_id: mod_id.to_owned(),
|
||||
item,
|
||||
slot,
|
||||
ticks: 0,
|
||||
});
|
||||
}
|
||||
/// Whether the player's use (`Game.using`) is the mod use under way. A
|
||||
/// use the game reset and another item's begun since are not.
|
||||
fn owns(&self, using: Option<(usize, u32)>) -> bool {
|
||||
self.using
|
||||
.as_ref()
|
||||
.is_some_and(|u| using == Some((u.slot, u.ticks)))
|
||||
}
|
||||
fn tick(&mut self) {
|
||||
if let Some(u) = &mut self.using {
|
||||
u.ticks += 1;
|
||||
}
|
||||
}
|
||||
/// The use under way ends: its mod hears `UseStop`.
|
||||
fn end(&mut self) {
|
||||
if let Some(u) = self.using.take() {
|
||||
self.push(&u.mod_id, NativeModEvent::UseStop(u.item, u.ticks));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The stack in inventory slot `slot` as a mod's events carry it.
|
||||
fn mod_held(&self, slot: usize) -> Option<NativeHeldItem> {
|
||||
let stack = self.stack_in(slot)?;
|
||||
let extra = stack.extra.as_deref();
|
||||
Some(NativeHeldItem {
|
||||
id: stack.item.to_string(),
|
||||
count: stack.count,
|
||||
slot: slot as u32,
|
||||
custom_name: extra
|
||||
.and_then(|e| e.custom_name.as_ref())
|
||||
.map(horizon_client::snbt::to_snbt),
|
||||
custom_data: extra
|
||||
.and_then(|e| e.other("minecraft:custom_data"))
|
||||
.map(horizon_client::snbt::to_snbt),
|
||||
})
|
||||
}
|
||||
/// The first block along the view within `distance` (at most 1024), past the loaded chunks over the distant terrain. API @API:native_claimed_items@.
|
||||
pub(super) fn native_raycast(&self, distance: f32) -> Option<NativeHit> {
|
||||
let (d, hit) = self.native_view_hit()?;
|
||||
(d <= distance.clamp(0.0, RAYCAST_MAX)).then_some(hit)
|
||||
}
|
||||
/// The first block along the view within `RAYCAST_MAX`, and its distance. API @API:native_claimed_items@.
|
||||
pub(super) fn native_view_hit(&self) -> Option<(f32, NativeHit)> {
|
||||
self.player.as_ref()?;
|
||||
let eye = self.eye_position()?;
|
||||
let dir = super::view_vector(self.yaw, self.pitch);
|
||||
let reach = f64::from(RAYCAST_MAX);
|
||||
let (pos, point, state) = match self.clip_ray(eye, dir, reach) {
|
||||
Some(hit) => (
|
||||
hit.pos,
|
||||
hit.point,
|
||||
self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
),
|
||||
None => self.lod_ray(eye, dir, reach)?,
|
||||
};
|
||||
let distance = (0..3)
|
||||
.map(|i| (point[i] - eye[i]).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
Some((
|
||||
distance as f32,
|
||||
NativeHit {
|
||||
block_x: pos[0],
|
||||
block_y: pos[1],
|
||||
block_z: pos[2],
|
||||
x: point[0],
|
||||
y: point[1],
|
||||
z: point[2],
|
||||
block: self.res.facts().get(state).name.to_string(),
|
||||
},
|
||||
))
|
||||
}
|
||||
/// A ray over the distant terrain (`Lod::ground`) past the loaded chunks, a block at a time.
|
||||
fn lod_ray(
|
||||
&self,
|
||||
eye: [f64; 3],
|
||||
dir: [f64; 3],
|
||||
reach: f64,
|
||||
) -> Option<([i32; 3], [f64; 3], horizon_world::registry::BlockStateId)> {
|
||||
let lod = self.lod.as_ref()?;
|
||||
let mut t = 0.0;
|
||||
while t <= reach {
|
||||
let p = [
|
||||
eye[0] + dir[0] * t,
|
||||
eye[1] + dir[1] * t,
|
||||
eye[2] + dir[2] * t,
|
||||
];
|
||||
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
|
||||
if !self.world.loaded(x, z)
|
||||
&& let Some((height, state)) = lod.ground(x, z)
|
||||
&& p[1] <= f64::from(height)
|
||||
{
|
||||
return Some(([x, height - 1, z], [p[0], f64::from(height), p[2]], state));
|
||||
}
|
||||
t += 1.0;
|
||||
}
|
||||
None
|
||||
}
|
||||
/// Right-clicks with these items (at most 64 ids) are mod `mod_id`'s; a later call replaces the list. API @API:native_claimed_items@.
|
||||
pub(super) fn native_claim_items(&mut self, mod_id: &str, ids: &[String]) {
|
||||
self.native_input.claim(mod_id, ids);
|
||||
}
|
||||
/// Mod `mod_id`'s events since its last poll (at most 256 wait). API @API:native_claimed_items@.
|
||||
pub(super) fn native_poll(&mut self, mod_id: &str) -> Vec<NativeModEvent> {
|
||||
self.native_input.poll(mod_id)
|
||||
}
|
||||
/// `use_hand`: a right-click with an item a mod claims starts its use for
|
||||
/// that mod and nothing else happens. True when a mod took it.
|
||||
pub(super) fn mod_use_start(&mut self) -> bool {
|
||||
if self.guest_use.is_some() || self.using.is_some() {
|
||||
return false;
|
||||
}
|
||||
// With no use, a mod use the game reset without telling it is over.
|
||||
self.native_input.end();
|
||||
let slot = self.held_slot();
|
||||
let Some(item) = self.mod_held(slot) else {
|
||||
return false;
|
||||
};
|
||||
let Some(id) = self.native_input.claimant(&item.id).map(str::to_owned) else {
|
||||
return false;
|
||||
};
|
||||
self.native_input.start(&id, item, slot);
|
||||
self.using = Some((slot, 0));
|
||||
true
|
||||
}
|
||||
/// `tick_used_item` for a claimed item: counts the ticks held and ends
|
||||
/// the use when the button comes up. True when the player's use is a
|
||||
/// mod's; a mod use the game reset ends, and the player's own use goes on.
|
||||
pub(super) fn mod_use_tick(&mut self, slot: usize, ticks: u32, released: bool) -> bool {
|
||||
if !self.native_input.owns(Some((slot, ticks))) {
|
||||
self.native_input.end();
|
||||
return false;
|
||||
}
|
||||
let still_claimed = self.native_input.using.as_ref().is_some_and(|u| {
|
||||
self.stack_in(slot)
|
||||
.is_some_and(|s| self.native_input.claimant(&s.item) == Some(u.mod_id.as_str()))
|
||||
});
|
||||
if released || !still_claimed {
|
||||
self.native_input.end();
|
||||
self.using = None;
|
||||
} else {
|
||||
self.native_input.tick();
|
||||
self.using = Some((slot, ticks + 1));
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The player's use was reset (a respawn, a join, the hand's item
|
||||
/// changed): a mod use under way ends and its mod hears `UseStop`.
|
||||
pub(super) fn end_mod_use(&mut self) {
|
||||
self.native_input.end();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod input_tests {
|
||||
use super::*;
|
||||
|
||||
fn stick(slot: u32) -> NativeHeldItem {
|
||||
NativeHeldItem {
|
||||
id: "minecraft:stick".into(),
|
||||
count: 1,
|
||||
slot,
|
||||
custom_name: None,
|
||||
custom_data: Some("{orbital:1b}".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_are_claimed_by_the_first_mod_in_id_order() {
|
||||
let mut input = ModInput::default();
|
||||
let ids = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
input.claim("zeta", &ids(&["orbital:remote", "minecraft:stick"]));
|
||||
input.claim("alpha", &ids(&["minecraft:stick"]));
|
||||
assert_eq!(input.claimant("minecraft:stick"), Some("alpha"));
|
||||
assert_eq!(input.claimant("orbital:remote"), Some("zeta"));
|
||||
assert_eq!(input.claimant("minecraft:dirt"), None);
|
||||
input.claim("alpha", &[]);
|
||||
assert_eq!(input.claimant("minecraft:stick"), Some("zeta"));
|
||||
let many: Vec<String> = (0..100).map(|i| format!("m:item{i}")).collect();
|
||||
input.claim("alpha", &many);
|
||||
assert_eq!(input.claimant("m:item63"), Some("alpha"));
|
||||
assert_eq!(input.claimant("m:item64"), None, "64 at most");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_wait_for_their_mod_up_to_a_limit() {
|
||||
let mut input = ModInput::default();
|
||||
for _ in 0..EVENTS_MAX + 10 {
|
||||
input.push("a", NativeModEvent::UseStart(stick(0), 0));
|
||||
}
|
||||
input.push("b", NativeModEvent::UseStop(stick(0), 3));
|
||||
assert_eq!(input.poll("a").len(), EVENTS_MAX);
|
||||
assert!(input.poll("a").is_empty());
|
||||
assert_eq!(input.poll("b"), vec![NativeModEvent::UseStop(stick(0), 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_claimed_use_is_the_mods_while_it_runs() {
|
||||
let mut input = ModInput::default();
|
||||
input.start("orbital", stick(2), 2);
|
||||
assert!(input.owns(Some((2, 0))));
|
||||
input.tick();
|
||||
input.tick();
|
||||
assert!(input.owns(Some((2, 2))));
|
||||
input.end();
|
||||
assert!(!input.owns(Some((2, 2))));
|
||||
assert_eq!(
|
||||
input.poll("orbital"),
|
||||
vec![
|
||||
NativeModEvent::UseStart(stick(2), 0),
|
||||
NativeModEvent::UseStop(stick(2), 2),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interrupted_claimed_use_leaves_the_next_use_alone() {
|
||||
// A respawn ends the use: the mod hears it stop, and a vanilla use
|
||||
// begun next in the same slot is not taken for the mod's.
|
||||
let mut input = ModInput::default();
|
||||
input.start("orbital", stick(0), 0);
|
||||
input.tick();
|
||||
input.end();
|
||||
assert!(!input.owns(Some((0, 0))));
|
||||
assert_eq!(
|
||||
input.poll("orbital").last(),
|
||||
Some(&NativeModEvent::UseStop(stick(0), 1))
|
||||
);
|
||||
// A reset the mod use never heard of (`Game.using` cleared alone):
|
||||
// the player's next use, from 0 ticks or in another slot, is still
|
||||
// not the mod's, and ending it then tells the mod once.
|
||||
input.start("orbital", stick(0), 0);
|
||||
input.tick();
|
||||
input.tick();
|
||||
assert!(!input.owns(None));
|
||||
assert!(!input.owns(Some((0, 0))), "a vanilla use from 0 ticks");
|
||||
assert!(!input.owns(Some((1, 2))), "another slot");
|
||||
input.end();
|
||||
input.end();
|
||||
let events = input.poll("orbital");
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[1], NativeModEvent::UseStop(stick(0), 2));
|
||||
}
|
||||
}
|
||||
@@ -298,6 +298,7 @@ use horizon_client::{
|
||||
keybinds,
|
||||
mobs::{Damage, Who},
|
||||
mod_list::{self, Installed, NativePage},
|
||||
music::{Music, MusicLayer},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
text::Text,
|
||||
};
|
||||
@@ -1185,6 +1186,35 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (context, phase, error_text);
|
||||
}
|
||||
/// The server is about to send its data to the players (NeoForge's
|
||||
/// `OnDatapackSyncEvent`, its `sendRecipes`): push the recipe types
|
||||
/// (`minecraft:smelting`, `mymod:crusher`) whose recipes your client half
|
||||
/// needs. Horizon sends their final recipes — the world's data packs
|
||||
/// merged, load conditions held — to every Horizon client when it joins
|
||||
/// and again after each `/reload`; a vanilla client is sent nothing new.
|
||||
/// Asked once per load, when the server's data tables are built, and the
|
||||
/// same types go to every player: unlike NeoForge, which asks again for
|
||||
/// each joining player, it can't differ between players. Push nothing and
|
||||
/// nothing is sent; push nothing after a load that pushed some and the
|
||||
/// clients go back to their own recipes. Server-side (the single
|
||||
/// player's integrated server included). API @API:recipe_sync@.
|
||||
pub(super) fn native_datapack_sync(&mut self, recipe_types: &mut Vec<String>) {
|
||||
let _ = recipe_types;
|
||||
}
|
||||
/// The server's recipes of `recipe_types` arrived (NeoForge's
|
||||
/// `RecipesReceivedEvent`): from now on the recipe helpers
|
||||
/// (`native_recipes`, `native_recipes_of_type`) give the server's recipes
|
||||
/// of those types in place of the window's own, so rebuild what you made
|
||||
/// of them. It comes after a join, once the window has its player, and
|
||||
/// after every `/reload`, on a server whose mods asked for them
|
||||
/// (`native_datapack_sync`), single player included; with no types when
|
||||
/// the server stopped sending them and the window is back on its own
|
||||
/// recipes. A recipe the
|
||||
/// window's own recipe book doesn't know has `book_id` -1. Client-side.
|
||||
/// API @API:recipe_sync@.
|
||||
pub(super) fn native_recipes_received(&mut self, recipe_types: &[String]) {
|
||||
let _ = recipe_types;
|
||||
}
|
||||
/// The server's data tables are built (NeoForge's `TagsUpdatedEvent`
|
||||
/// with `SERVER_DATA_LOAD`): the recipes, tags, loot and advancements of
|
||||
/// the world's data packs, and the files mods added through
|
||||
@@ -1323,6 +1353,12 @@ impl Game<'_> {
|
||||
pub(super) fn native_key(&mut self, key: &str, pressed: bool) {
|
||||
let _ = (key, pressed);
|
||||
}
|
||||
/// A real window key event after handling, including repeats and consumed shortcuts.
|
||||
/// Receives the original physical/logical key, text, location, state and repeat flag.
|
||||
/// Modifier bits use the screen hooks' GLFW encoding. Not cancellable.
|
||||
pub(super) fn native_input_key(&mut self, event: &winit::event::KeyEvent, modifiers: u32) {
|
||||
let _ = (event, modifiers);
|
||||
}
|
||||
/// Raw mouse button input on any screen, before dispatch; cancel skips dispatch and Post.
|
||||
/// Button/modifiers use the same GLFW encoding as screen hooks. Synthetic clicks skip it.
|
||||
pub(super) fn native_mouse_button_pre(
|
||||
@@ -1342,6 +1378,20 @@ impl Game<'_> {
|
||||
pub(super) fn native_wheel(&mut self, steps: f32, claimed: &mut bool) {
|
||||
let _ = (steps, claimed);
|
||||
}
|
||||
/// Gameplay scrolling after whole steps accumulate, before hotbar/spectator handling.
|
||||
/// `scroll` is scaled x/y, `accumulated` whole x/y steps, `buttons` left/middle/right,
|
||||
/// and `cursor` the GUI position. Cancel skips gameplay scrolling; synthetic input skips it.
|
||||
pub(super) fn native_mouse_scrolling(
|
||||
&mut self,
|
||||
scroll: [f64; 2],
|
||||
accumulated: [i32; 2],
|
||||
buttons: [bool; 3],
|
||||
cursor: [f32; 2],
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (scroll, accumulated, buttons, cursor, modifiers, canceled);
|
||||
}
|
||||
/// A command typed or clicked in chat, without its slash, before the
|
||||
/// server and Horizon's commands see it: set `ran` when the mod ran it.
|
||||
pub(super) fn native_command(&mut self, command: &str, ran: &mut bool) {
|
||||
@@ -1377,6 +1427,18 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (partial, camera, fog_type, rgb);
|
||||
}
|
||||
/// Final world fog, after the computed color hook, ordinary calculations and Horizon's fog
|
||||
/// option, before upload. Title panoramas skip it. Invalid color or distance values retain the
|
||||
/// original whole fog.
|
||||
pub(super) fn native_render_fog(
|
||||
&self,
|
||||
partial: f32,
|
||||
camera: [f64; 3],
|
||||
fog_type: horizon_client::fog::FogType,
|
||||
fog: &mut horizon_client::fog::FogData,
|
||||
) {
|
||||
let _ = (partial, camera, fog_type, fog);
|
||||
}
|
||||
/// The camera shake native mods ask for in this frame, `partial` ticks
|
||||
/// past the last tick (0 to 1): add your pitch, yaw and roll in degrees
|
||||
/// and your offset in blocks, along the camera's right, up and back, to
|
||||
@@ -1414,13 +1476,6 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (partial, mirrored, entity, distance);
|
||||
}
|
||||
/// The values native mods hand their shader packs in this frame,
|
||||
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
|
||||
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
|
||||
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
|
||||
/// one that pack is for. Asked once a frame while a pack is on; a value
|
||||
/// that isn't finite reads as 0 (`native_shader_params_now`). API
|
||||
/// @API:native_shader_params@.
|
||||
/// Change the colours of `biome` (`minecraft:plains`) on this client: the
|
||||
/// grass, foliage (leaves, vines), dry foliage (leaf litter) and water
|
||||
/// tints, 0xRRGGBB, as its biome file and the colormaps set them. Asked
|
||||
@@ -1432,6 +1487,36 @@ impl Game<'_> {
|
||||
pub(super) fn native_biome_colors(&self, biome: &str, colors: &mut BiomeColors) {
|
||||
let _ = (biome, colors);
|
||||
}
|
||||
/// Choose this client's music, once a client tick: `music` and `volume`
|
||||
/// hold what vanilla chose (`Minecraft.getSituationalMusic` and
|
||||
/// `getMusicVolume`: the biome's track, the creative or underwater one, a
|
||||
/// boss's, the menu's; None for silence). Set `music` to a track of your
|
||||
/// own, a sound event of your content mod's `sounds.json` such as
|
||||
/// `Music::new("mymod:music.spring", 1200, 6000, false)` (the delays in
|
||||
/// ticks between songs, and whether it cuts off a different song playing),
|
||||
/// None to keep vanilla's quiet, or change `volume` (0 to 1) to fade it. API
|
||||
/// @API:music@.
|
||||
pub(super) fn native_music(&self, music: &mut Option<Music>, volume: &mut f32) {
|
||||
let _ = (music, volume);
|
||||
}
|
||||
/// The tracks to play over (or instead of) the music on this client,
|
||||
/// asked once a client tick: push a `MusicLayer` (a sound event, looped,
|
||||
/// and its volume, 0 to 1) for each. A layer eases to a new volume over up
|
||||
/// to two seconds, and one left out fades out and stops; layers that
|
||||
/// first appear in the same tick start together, so list every stem of a
|
||||
/// piece from the start, the silent ones at volume 0, and raise them (a
|
||||
/// fuller arrangement as a village grows). Their sound events should be
|
||||
/// `stream: true` in `sounds.json`. API @API:music@.
|
||||
pub(super) fn native_music_layers(&self, layers: &mut Vec<MusicLayer>) {
|
||||
let _ = layers;
|
||||
}
|
||||
/// The values native mods hand their shader packs in this frame,
|
||||
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
|
||||
/// them as `frame.mods` (eight `vec4<f32>`). Write the slots your own
|
||||
/// pack reads; a mod that bundles its pack as `native-<mod id>` is the
|
||||
/// one that pack is for. Asked once a frame while a pack is on; a value
|
||||
/// that isn't finite reads as 0 (`native_shader_params_now`). API
|
||||
/// @API:native_shader_params@.
|
||||
pub(super) fn native_shader_params(&self, partial: f32, params: &mut [[f32; 4]; 8]) {
|
||||
let _ = (partial, params);
|
||||
}
|
||||
@@ -1725,6 +1810,19 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (screen, button, modifiers, canceled);
|
||||
}
|
||||
/// Before a real held-button movement reaches screen drag handlers.
|
||||
/// `screen.mouse` is the current GUI position; `delta` is its GUI-space movement.
|
||||
/// Cancel keeps the new cursor position but skips all screen drag handlers.
|
||||
pub(super) fn native_screen_mouse_dragged_pre(
|
||||
&self,
|
||||
screen: &NativeScreenView<'_>,
|
||||
button: i32,
|
||||
delta: [f32; 2],
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (screen, button, delta, modifiers, canceled);
|
||||
}
|
||||
/// The wheel turned over the open screen (`ScreenEvent.MouseScrolled.Pre`,
|
||||
/// vanilla's `Screen.mouseScrolled`): `screen` is the screen it is,
|
||||
/// `screen.mouse` where the pointer is (so there is no position of its
|
||||
@@ -1792,6 +1890,17 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (screen, key, repeat, modifiers, canceled);
|
||||
}
|
||||
/// Before an open screen handles a named key's release (same screen gate as key presses).
|
||||
/// Cancel bypasses screen handling; physical keys and mapped keyboard hold/toggle state release.
|
||||
pub(super) fn native_screen_key_released_pre(
|
||||
&self,
|
||||
screen: &NativeScreenView<'_>,
|
||||
key: &str,
|
||||
modifiers: u32,
|
||||
canceled: &mut bool,
|
||||
) {
|
||||
let _ = (screen, key, modifiers, canceled);
|
||||
}
|
||||
/// A character was typed at the open screen
|
||||
/// (`ScreenEvent.CharacterTyped.Pre`, vanilla's `Screen.charTyped`):
|
||||
/// `screen` is the screen it is, `c` the character, and `modifiers` GLFW's
|
||||
@@ -1857,6 +1966,34 @@ impl Game<'_> {
|
||||
image.height as f32,
|
||||
]
|
||||
}
|
||||
pub(super) fn track_drag_button(&mut self, button: MouseButton, pressed: bool) {
|
||||
// Any release ends the drag, as vanilla's `MouseHandler.onButton` clears `activeButton`:
|
||||
// left down, right down, left up sends no drag while right is still held.
|
||||
self.drag_button = pressed.then_some(button);
|
||||
}
|
||||
pub(super) fn screen_mouse_dragged(&self, previous: (f32, f32)) -> bool {
|
||||
if self.pad_synthetic_input() || self.cursor == previous {
|
||||
return false;
|
||||
}
|
||||
let Some(button) = self
|
||||
.drag_button
|
||||
.filter(|b| self.physical_buttons.contains(b))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(screen) = self.screen_view_now() else {
|
||||
return false;
|
||||
};
|
||||
let mut canceled = false;
|
||||
self.native_screen_mouse_dragged_pre(
|
||||
&screen,
|
||||
super::glfw_button(button),
|
||||
[self.cursor.0 - previous.0, self.cursor.1 - previous.1],
|
||||
super::glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
canceled
|
||||
}
|
||||
/// A screen the native mods' input hooks are for is open: the window has a
|
||||
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
|
||||
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
|
||||
@@ -1904,6 +2041,22 @@ impl Game<'_> {
|
||||
);
|
||||
canceled
|
||||
}
|
||||
pub(super) fn screen_key_released(&self, key: KeyCode) -> bool {
|
||||
let Some(name) = keybinds::key_name(key) else {
|
||||
return false;
|
||||
};
|
||||
let Some(screen) = self.screen_view_now() else {
|
||||
return false;
|
||||
};
|
||||
let mut canceled = false;
|
||||
self.native_screen_key_released_pre(
|
||||
&screen,
|
||||
name,
|
||||
super::glfw_modifiers(self.modifiers),
|
||||
&mut canceled,
|
||||
);
|
||||
canceled
|
||||
}
|
||||
/// Text on its way to a text box after the open screen's character hooks
|
||||
/// (`native_screen_char_typed_pre` per printable character): the cancelled
|
||||
/// characters are gone and every other one stays, control characters
|
||||
@@ -2386,6 +2539,25 @@ impl Game<'_> {
|
||||
pub(super) fn native_player_respawned(&mut self, player: u128, name: &str) {
|
||||
let _ = (player, name);
|
||||
}
|
||||
/// The number of players with the mod container at `pos` of `dimension`
|
||||
/// open went from `previous` to `viewers` (vanilla's
|
||||
/// `ContainerOpenersCounter`, as a barrel keeps it): 0 to more than 0 is
|
||||
/// `onOpen`, back to 0 is `onClose`, any change `openerCountChanged`, so a
|
||||
/// block can show itself open while `viewers` is not 0. Counted every
|
||||
/// tick from the players' open menus, so a player who leaves without
|
||||
/// closing (a lost connection, death, another dimension, out of reach, the
|
||||
/// block gone) is heard too (`recheckOpeners`). Once for each change, with
|
||||
/// no player's state swapped in. Server-side.
|
||||
/// @API:mod_container_viewers@.
|
||||
pub(super) fn native_mod_container_viewers(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
pos: [i32; 3],
|
||||
viewers: u32,
|
||||
previous: u32,
|
||||
) {
|
||||
let _ = (dimension, pos, viewers, previous);
|
||||
}
|
||||
/// `player` died: where `ServerPlayer.die` starts, with the death
|
||||
/// message's key when the game knows it (`death.attack.lava`; none for a
|
||||
/// message it builds from the combat). It only hears. Server-side, for
|
||||
@@ -3684,7 +3856,8 @@ impl Game<'_> {
|
||||
/// nobody uses and outside a world. These are the server's table on the
|
||||
/// server thread, a dedicated server or a LAN host; a client, the
|
||||
/// single-player window included, sees only the content mods' recipes, not
|
||||
/// the world's data packs'. API 36.
|
||||
/// the world's data packs', unless a mod had the server send it the type
|
||||
/// (`native_datapack_sync`). API 36.
|
||||
pub(super) fn native_recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
|
||||
if self.outside_world() {
|
||||
return &[];
|
||||
@@ -3743,9 +3916,10 @@ impl Game<'_> {
|
||||
self.player.is_none() && self.options.execution_role.is_client()
|
||||
}
|
||||
/// Every item and recipe the game knows, made once (the same for every
|
||||
/// call); none outside a world. They are the window's own: the world's
|
||||
/// in single player, vanilla's on a server, which sends a client no
|
||||
/// others.
|
||||
/// call); none outside a world. They are the window's own (the content
|
||||
/// mods' and vanilla's), with the server's recipes of the types a mod had
|
||||
/// it send (`native_datapack_sync`) in their place; made again when those
|
||||
/// arrive (`native_recipes_received`).
|
||||
pub(super) fn native_recipes(&mut self) -> Option<Arc<NativeRecipes>> {
|
||||
self.player.as_ref()?;
|
||||
self.made_recipes()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Every item and recipe the game knows, made once for the mods that read
|
||||
//! them (native mods' `native_recipes`, capability `recipes` of WebAssembly
|
||||
//! mods, docs/mods.md), and the recipe book's known displays; placing a
|
||||
//! recipe and creative gifts. The recipes are the window's own data: the
|
||||
//! world's in single player, vanilla's on a server (a client is sent no
|
||||
//! others), while the known displays follow the server's book as the recipe
|
||||
//! book does.
|
||||
//! recipe and creative gifts. The recipes are the window's own data — the
|
||||
//! content root's — with the server's recipes of the types its native mods
|
||||
//! had it send (`horizon:recipe_content`) in their place, while the known
|
||||
//! displays follow the server's book as the recipe book does.
|
||||
use super::{
|
||||
Game,
|
||||
native_hooks::{
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
},
|
||||
};
|
||||
use horizon_client::{
|
||||
crafting::Ingredient,
|
||||
crafting::{Ingredient, recipe_id},
|
||||
items::ItemStack,
|
||||
recipe_book::{Category, Display},
|
||||
stations::Smithing,
|
||||
@@ -22,10 +22,22 @@ use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
/// The tables, made once, and the recipe book's known displays with the
|
||||
/// book's version they are of.
|
||||
/// The most recipes a server's recipe content may carry.
|
||||
const MAX_SYNCED_RECIPES: usize = 1_000_000;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct RecipeTables {
|
||||
tables: Option<Arc<Recipes>>,
|
||||
known: (Option<u64>, Arc<[i32]>),
|
||||
/// The server's recipes of the types its native mods sent (`horizon:
|
||||
/// recipe_content`) over the window's own: what `Game::recipes` reads on
|
||||
/// a client once they came.
|
||||
synced: Option<horizon_client::crafting::Recipes>,
|
||||
/// The recipe content's parts so far, until its last.
|
||||
incoming: Vec<(String, serde_json::Value)>,
|
||||
/// The types that came before the player did: the mods hear of them
|
||||
/// once the helpers can answer (`recipes_received_now`).
|
||||
received: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Item id -> its index in [`Recipes::items`].
|
||||
@@ -187,11 +199,10 @@ impl Game<'_> {
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
let cooking: HashMap<String, (u32, f32)> = self
|
||||
.res
|
||||
.recipes()
|
||||
.cooking
|
||||
.iter()
|
||||
.map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience)))
|
||||
.map(|c| (recipe_id(&c.id), (c.time, c.experience)))
|
||||
.collect();
|
||||
for e in self.recipe_book_entries() {
|
||||
let furnace = |c: Category| match c.name().split('_').next() {
|
||||
@@ -243,7 +254,7 @@ impl Game<'_> {
|
||||
}
|
||||
for c in &self.recipes().cooking {
|
||||
if c.kind == "minecraft:campfire_cooking" {
|
||||
let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire);
|
||||
let mut r = recipe(recipe_id(&c.id), RecipeKind::Campfire);
|
||||
r.group = c.group.clone();
|
||||
r.slots = vec![index.any(&c.ingredient)];
|
||||
r.results = index.of(&c.result).into_iter().collect();
|
||||
@@ -320,3 +331,107 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The server's recipes a client was sent, over its own; None on a
|
||||
/// server and until a server sends any.
|
||||
pub(super) fn synced_recipes(&self) -> Option<&horizon_client::crafting::Recipes> {
|
||||
self.recipe_tables.synced.as_ref()
|
||||
}
|
||||
/// A new play session (`Login`): what the last server sent is gone.
|
||||
pub(super) fn forget_synced_recipes(&mut self) {
|
||||
self.recipe_tables.synced = None;
|
||||
self.recipe_tables.incoming.clear();
|
||||
self.recipe_tables.received = None;
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
}
|
||||
/// `native_recipes_received` for the types that came, once the window
|
||||
/// has its player: the recipes of a join come before it, and the helpers
|
||||
/// answer nothing until then. Once a frame.
|
||||
pub(super) fn recipes_received_now(&mut self) {
|
||||
if self.player.is_some()
|
||||
&& let Some(types) = self.recipe_tables.received.take()
|
||||
{
|
||||
self.native_recipes_received(&types);
|
||||
}
|
||||
}
|
||||
/// `OnDatapackSyncEvent.sendRecipes`: the recipe types the native mods
|
||||
/// want their clients to have, from this load's tables, become what the
|
||||
/// server sends its Horizon clients at a join and after `/reload`. Called
|
||||
/// once a load's tables are built, before any player hears of them.
|
||||
pub(super) fn sync_recipe_content(&mut self) {
|
||||
use horizon_client::lan::recipes;
|
||||
let mut types = Vec::new();
|
||||
self.native_datapack_sync(&mut types);
|
||||
types.sort();
|
||||
types.dedup();
|
||||
let sources = if types.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
horizon_client::crafting::Recipes::sources(&self.options.resources, &self.pack_files())
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!("Horizon: the recipes for the clients were not read: {e}");
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
recipes::set_content(recipes::encode_content(
|
||||
&types,
|
||||
&sources,
|
||||
recipes::sent_before(),
|
||||
));
|
||||
}
|
||||
/// A `horizon:recipe_content` part the server sent the window. On the
|
||||
/// last, the window's tables take the server's recipes of those types in
|
||||
/// place of its own, and the mods hear of it (`RecipesReceivedEvent`).
|
||||
pub(super) fn remote_recipe_content(&mut self, data: &[u8]) {
|
||||
let part: horizon_client::lan::recipes::ContentPart = match serde_json::from_slice(data) {
|
||||
Ok(part) => part,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: bad recipe content from the server: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.recipe_tables.incoming.extend(part.recipes);
|
||||
// The server is trusted to end its parts; one that never does is
|
||||
// dropped, not kept growing.
|
||||
if self.recipe_tables.incoming.len() > MAX_SYNCED_RECIPES {
|
||||
log::warn!("Horizon: the server sent more than {MAX_SYNCED_RECIPES} recipes; dropped");
|
||||
self.recipe_tables.incoming = Vec::new();
|
||||
return;
|
||||
}
|
||||
let Some(types) = part.last else {
|
||||
return;
|
||||
};
|
||||
let received = std::mem::take(&mut self.recipe_tables.incoming);
|
||||
// No type any more: back on the window's own recipes.
|
||||
if types.is_empty() {
|
||||
if self.recipe_tables.synced.take().is_some() {
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
self.recipe_tables.received = Some(types);
|
||||
self.recipes_received_now();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let root = &self.options.resources;
|
||||
let overlay = horizon_world::loot::PackOverlay::NONE;
|
||||
let built = horizon_client::crafting::Recipes::sources(root, &overlay).and_then(|own| {
|
||||
let of = |v: &serde_json::Value| types.iter().any(|t| v["type"].as_str() == Some(t));
|
||||
let mut all: Vec<_> = own.into_iter().filter(|(_, v)| !of(v)).collect();
|
||||
all.extend(received);
|
||||
horizon_client::crafting::Recipes::from_sources(root, self.items(), &overlay, all)
|
||||
});
|
||||
match built {
|
||||
Ok(recipes) => {
|
||||
self.recipe_tables.synced = Some(recipes);
|
||||
// What was made of the window's own recipes is made again.
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
self.recipe_tables.received = Some(types);
|
||||
self.recipes_received_now();
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: the server's recipes were not taken: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -628,7 +628,8 @@ fn add_to(
|
||||
/// screen as any change to their inventory does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The main hand's stack: its item, count, and whether it is plain.
|
||||
/// The main hand's stack: its item, count, and whether it is plain. On a
|
||||
/// client, the player's own.
|
||||
pub(super) fn native_held(&self) -> Option<(String, u32, bool)> {
|
||||
let s = self.inventory.get(self.selected)?.as_ref()?;
|
||||
Some((s.item.to_string(), s.count, plain(s)))
|
||||
|
||||
@@ -148,6 +148,23 @@ impl Game<'_> {
|
||||
self.ui.vanilla_root.clone()
|
||||
}
|
||||
}
|
||||
/// Whether all active packs preserve vanilla's block models. The server
|
||||
/// overlay sits above the local selection and must be included too.
|
||||
pub(super) fn model_packs_are_vanilla(&self) -> bool {
|
||||
self.settings.horizon.vanilla_models_with_server_packs(
|
||||
self.settings.resource_packs.iter().map(String::as_str),
|
||||
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
|
||||
)
|
||||
}
|
||||
/// The leaf mode that is safe for the current local and server packs.
|
||||
pub(super) fn effective_leaf_culling(&self) -> horizon_client::graphics::LeafCullingMode {
|
||||
self.settings
|
||||
.horizon
|
||||
.leaf_culling_effective_with_server_packs(
|
||||
self.settings.resource_packs.iter().map(String::as_str),
|
||||
self.server_packs.active.iter().map(|pack| pack.id.as_str()),
|
||||
)
|
||||
}
|
||||
/// The packs the pack screen offers and the current selection
|
||||
/// (`PackSelectionScreen` makes its `PackSelectionModel` from them).
|
||||
pub(super) fn refresh_pack_lists(&mut self) {
|
||||
@@ -355,11 +372,25 @@ impl Game<'_> {
|
||||
.cem
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
|
||||
// The random entity textures the new atlas packs: every mob chooses again.
|
||||
*self
|
||||
.res
|
||||
.mob_data()
|
||||
.random_textures
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) =
|
||||
std::mem::take(&mut self.gui.atlas.random_textures);
|
||||
for m in &mut self.mobs {
|
||||
m.random_textures.forget_picks();
|
||||
}
|
||||
horizon_client::ui::load_metadata(&root);
|
||||
lang::set(lang::Language::load(&[&root], &lang::code()));
|
||||
self.refresh_remote_language();
|
||||
self.audio.reload(&root);
|
||||
self.clear_lod();
|
||||
// A pack that may replace leaf models falls back to `Off` before the
|
||||
// reload marks every section dirty.
|
||||
self.streaming.leaf_culling = self.effective_leaf_culling();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.pending_uploads.clear();
|
||||
// New GPU textures: a renderer built from the reloaded resources; the
|
||||
|
||||
@@ -1416,6 +1416,7 @@ mod preview_tests {
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: Default::default(),
|
||||
random_textures: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
};
|
||||
|
||||
@@ -7,8 +7,8 @@ use horizon_client::{
|
||||
mobs::{
|
||||
Mob, Species, Who,
|
||||
finalize::{
|
||||
SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
|
||||
stored_drop_chance,
|
||||
SLOT_NAMES, item_stack, set_drop_chance, set_item, set_item_stack,
|
||||
set_slot_enchantments, slot_enchantments, stored_drop_chance,
|
||||
},
|
||||
piglin::{ADMIRE, ATE_RECENTLY},
|
||||
},
|
||||
@@ -27,8 +27,9 @@ impl Game<'_> {
|
||||
self.item_in_tag("minecraft:piglin_loved", item)
|
||||
}
|
||||
/// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`).
|
||||
fn piglin_wanted(&self, item: &str) -> bool {
|
||||
self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item)
|
||||
fn piglin_wanted(&self, m: &Mob, item: &str) -> bool {
|
||||
self.piglin_loved(item)
|
||||
|| !m.is_baby() && self.item_in_tag("minecraft:piglin_preferred_weapons", item)
|
||||
}
|
||||
/// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes
|
||||
/// to its slot, anything else to the main hand.
|
||||
@@ -50,7 +51,7 @@ impl Game<'_> {
|
||||
let Some(current) = horizon_client::mobs::finalize::item(m, slot) else {
|
||||
return true;
|
||||
};
|
||||
self.piglin_wanted(item) && !self.piglin_wanted(¤t)
|
||||
self.piglin_wanted(m, item) && !self.piglin_wanted(m, ¤t)
|
||||
}
|
||||
/// `SimpleContainer.canAddItem` on the piglin's eight slots.
|
||||
fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool {
|
||||
@@ -62,11 +63,13 @@ impl Game<'_> {
|
||||
fn piglin_max_stack(&self, item: &str) -> u32 {
|
||||
self.items().get(item).map_or(64, |i| i.max_stack)
|
||||
}
|
||||
/// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without
|
||||
/// the baby rule, which babies skip entirely here.
|
||||
/// `PiglinAi.wantsToPickup`: babies ignore their own item tag, but can
|
||||
/// pick up currency and other loved items just like adults.
|
||||
fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool {
|
||||
let item = &*stack.item;
|
||||
if self.item_in_tag("minecraft:piglin_repellents", item) {
|
||||
if m.is_baby() && self.item_in_tag("minecraft:ignored_by_piglin_babies", item)
|
||||
|| self.item_in_tag("minecraft:piglin_repellents", item)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let holds_loved = m
|
||||
@@ -191,8 +194,7 @@ impl Game<'_> {
|
||||
}
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if m.species() != Species::Piglin || !m.alive() || m.is_baby() || m.extra.using_item > 0
|
||||
{
|
||||
if m.species() != Species::Piglin || !m.alive() || m.extra.using_item > 0 {
|
||||
continue;
|
||||
}
|
||||
let pos = m.position;
|
||||
@@ -302,8 +304,8 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a
|
||||
/// roll of the bartering table, thrown toward the player; any other loved
|
||||
/// item it picked up is kept (worn if it can be, else stored).
|
||||
/// roll of the bartering table for adults. Babies equip the admired item,
|
||||
/// or hold it in the main hand after stashing or throwing the old item.
|
||||
pub(super) fn finish_admiring(&mut self, mob: u32) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
|
||||
return;
|
||||
@@ -311,22 +313,40 @@ impl Game<'_> {
|
||||
let Some(item) = self.mobs[i].extra.off_hand.take() else {
|
||||
return;
|
||||
};
|
||||
if self.mobs[i].is_baby() {
|
||||
let stack = ItemStack::new(&item, 1);
|
||||
if !self.piglin_equip(i, &stack) {
|
||||
if let Some(old) = item_stack(&self.mobs[i], 0) {
|
||||
if self.piglin_loved(&old.item) {
|
||||
self.piglin_stash(i, old);
|
||||
} else {
|
||||
self.piglin_throw_items(i, vec![old]);
|
||||
}
|
||||
}
|
||||
let m = &mut self.mobs[i];
|
||||
set_item_stack(m, 0, stack);
|
||||
set_drop_chance(m, 0, 2.0);
|
||||
m.persistent = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if &*item != "minecraft:gold_ingot" {
|
||||
self.piglin_keep(i, ItemStack::new(&item, 1));
|
||||
return;
|
||||
}
|
||||
let items = world_items!(self).loot_table(
|
||||
"minecraft:gameplay/piglin_bartering",
|
||||
false,
|
||||
None,
|
||||
&mut self.world.random,
|
||||
);
|
||||
self.piglin_throw_items(i, items);
|
||||
}
|
||||
/// `PiglinAi.throwItems`: barters and a baby's unwanted main-hand item
|
||||
/// use the same player-directed throw.
|
||||
fn piglin_throw_items(&mut self, i: usize, items: Vec<ItemStack>) {
|
||||
let m = &self.mobs[i];
|
||||
let from = [m.position.x, m.position.y + 1.3, m.position.z];
|
||||
let items = if !m.is_baby() {
|
||||
world_items!(self).loot_table(
|
||||
"minecraft:gameplay/piglin_bartering",
|
||||
false,
|
||||
None,
|
||||
&mut self.world.random,
|
||||
)
|
||||
} else {
|
||||
vec![ItemStack::new(&item, 1)]
|
||||
};
|
||||
// BehaviorUtils.throwItem toward the nearest visible player
|
||||
// (NEAREST_VISIBLE_PLAYER: within 16, alive, no spectators; the
|
||||
// guests count as much as the host), else nearby.
|
||||
|
||||
@@ -507,6 +507,12 @@ impl Game<'_> {
|
||||
)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
});
|
||||
// Entity.move updates an authoritative living entity's ground flag
|
||||
// even for a horizontal piston push. Keeping the old support flag
|
||||
// would run Frost Walker while the stand is moving over open water.
|
||||
if let Target::ArmorStand(i) = target {
|
||||
self.armor_stands[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
|
||||
}
|
||||
if resolved.length_squared() <= 1.0e-7
|
||||
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
|
||||
{
|
||||
|
||||
@@ -1140,6 +1140,15 @@ impl Game<'_> {
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
for (stack, at, mob, block, age, owner) in landed {
|
||||
// Projectile.onHit (super.onHit of AbstractThrownPotion): a block
|
||||
// is heard at its centre, an entity at the hit.
|
||||
let heard = if mob.is_some() {
|
||||
[at.x, at.y, at.z]
|
||||
} else {
|
||||
[at.x.floor() + 0.5, at.y.floor() + 0.5, at.z.floor() + 0.5]
|
||||
};
|
||||
let thrower = owner.is_some().then_some(Who::Player);
|
||||
self.projectile_event("minecraft:projectile_land", thrower, heard);
|
||||
self.splash(&stack, at, mob, block, age, owner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! OptiFine random entity textures: each mob's variants, chosen when this window first sees it
|
||||
//! and checked again once a second (`horizon_client::mobs::random_textures`).
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::mobs::random_textures::{Seen, choose, seed};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_optifine::random::Weather;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_random_textures(&mut self) {
|
||||
let res = self.res.clone();
|
||||
let textures = res
|
||||
.mob_data()
|
||||
.random_textures
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if textures.is_empty() {
|
||||
return;
|
||||
}
|
||||
let tick = self.world.game_time;
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if !m.random_textures.due(tick, m.id) {
|
||||
continue;
|
||||
}
|
||||
let seed = seed(m, self.persist.uuids.get(&m.id).copied());
|
||||
let seen = (!m.random_textures.has_seen()).then(|| self.seen_at(m.position));
|
||||
choose(&mut self.mobs[i], &textures, seen, seed);
|
||||
}
|
||||
}
|
||||
|
||||
/// What OptiFine reads of a mob's surroundings when it first appears.
|
||||
fn seen_at(&self, at: Vec3) -> Seen {
|
||||
let (x, y, z) = (
|
||||
at.x.floor() as i32,
|
||||
at.y.floor() as i32,
|
||||
at.z.floor() as i32,
|
||||
);
|
||||
let block = |y| {
|
||||
let state = self.pack.assets.registry.state(self.world.block(x, y, z))?;
|
||||
let properties = state
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
Some((state.name.clone(), properties))
|
||||
};
|
||||
let weather = &self.world.weather;
|
||||
Seen {
|
||||
biome: self.biome_name_at(at),
|
||||
y,
|
||||
blocks: [block(y - 1), block(y)].into_iter().flatten().collect(),
|
||||
moon_phase: self.world.day_time.div_euclid(24_000).rem_euclid(8) as i32,
|
||||
day_time: self.world.day_time.rem_euclid(24_000) as i32,
|
||||
weather: if weather.thundering {
|
||||
Weather::Thunder
|
||||
} else if weather.raining {
|
||||
Weather::Rain
|
||||
} else {
|
||||
Weather::Clear
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,6 +369,11 @@ impl Game<'_> {
|
||||
self.recipe_ui.client.displays(),
|
||||
)
|
||||
}
|
||||
/// The book's model is made again from `Game::recipes` when next needed:
|
||||
/// the server's recipes arrived, or a new session began.
|
||||
pub(super) fn forget_book(&mut self) {
|
||||
self.recipe_ui.book = None;
|
||||
}
|
||||
fn ensure_book(&mut self) {
|
||||
if self.recipe_ui.book.is_none() {
|
||||
let mut book = Book::build(self.recipes(), self.items());
|
||||
|
||||
@@ -431,6 +431,8 @@ pub(super) struct Remote {
|
||||
pub quiet_commands: bool,
|
||||
/// The server announced it carries mod messages.
|
||||
pub mod_net: bool,
|
||||
/// The server samples far terrain for us, this far at most (`lod_net`).
|
||||
pub far_terrain: Option<horizon_client::lod_net::Offer>,
|
||||
pub(super) builtin: Arc<Builtin>,
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
@@ -880,6 +882,7 @@ impl Game<'_> {
|
||||
self.container = None;
|
||||
self.carried = None;
|
||||
self.using = None;
|
||||
self.end_mod_use();
|
||||
self.clicks.miss_time = 0;
|
||||
// A new LocalPlayer has no effects: the server sends what it has
|
||||
// (ClientboundUpdateMobEffectPacket on placement).
|
||||
@@ -934,6 +937,7 @@ impl Game<'_> {
|
||||
brand: "unknown".into(),
|
||||
quiet_commands: false,
|
||||
mod_net: false,
|
||||
far_terrain: None,
|
||||
builtin: Arc::new(builtin),
|
||||
status: lang
|
||||
.get("connect.connecting")
|
||||
@@ -1768,12 +1772,26 @@ impl Game<'_> {
|
||||
remote.mod_net = true;
|
||||
}
|
||||
}
|
||||
Event::FarTerrain(offer) => {
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = Some(offer);
|
||||
}
|
||||
}
|
||||
Event::Reconfiguring => {
|
||||
// handleConfigurationStart: the held-back chat shows, then
|
||||
// clearClientLevel opens ServerReconfigScreen over no level.
|
||||
self.flush_chat_queue();
|
||||
self.send_chat_acknowledgement();
|
||||
self.close_dialogs();
|
||||
// The server configuring us now (a proxy's next one)
|
||||
// offers its own far terrain, or none.
|
||||
if let Some(remote) = &mut self.remote {
|
||||
remote.far_terrain = None;
|
||||
}
|
||||
self.clear_lod();
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.session.reset_far_terrain();
|
||||
}
|
||||
self.player = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(false);
|
||||
@@ -2856,6 +2874,10 @@ impl Game<'_> {
|
||||
C::CustomPayload(p) if p.channel == horizon_protocol::mod_net::CHANNEL => {
|
||||
self.remote_native_net(&p.data);
|
||||
}
|
||||
// The server's recipes its native mods asked to send.
|
||||
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
|
||||
self.remote_recipe_content(&p.data);
|
||||
}
|
||||
// PingDebugMonitor.onPongReceived.
|
||||
C::PongResponse(p) => {
|
||||
let delay = self.debug_millis() - p.time;
|
||||
@@ -2863,6 +2885,7 @@ impl Game<'_> {
|
||||
}
|
||||
C::Login(p) => {
|
||||
self.test_highlights.clear();
|
||||
self.forget_synced_recipes();
|
||||
self.level.hardcore = p.hardcore;
|
||||
// handleLogin: startWaitingForNewLevel with Reason.OTHER, which
|
||||
// replaces ServerReconfigScreen.
|
||||
@@ -2910,6 +2933,7 @@ impl Game<'_> {
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
self.using = None;
|
||||
self.end_mod_use();
|
||||
// After a death (no KEEP_ENTITY_DATA) the new LocalPlayer
|
||||
// rides nothing: the server pairs a vehicle again with its
|
||||
// passengers. A dimension change keeps the vehicle it travels in.
|
||||
@@ -4346,6 +4370,15 @@ impl Game<'_> {
|
||||
p.unsigned_content.as_ref(),
|
||||
&line,
|
||||
);
|
||||
// ChatListener.showMessageToPlayer: `Minecraft.isBlocked` hides the
|
||||
// message of a player hidden in the Social Interactions screen; it
|
||||
// counts as processed, not shown.
|
||||
if self.social_hides(p.sender) {
|
||||
if let Some(signature) = &signature {
|
||||
self.mark_message_processed(signature, false);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.handle_player_chat(line, signature, trust, narration);
|
||||
}
|
||||
C::DisguisedChat(p) => {
|
||||
@@ -5374,6 +5407,10 @@ impl Game<'_> {
|
||||
self.native_net.to_server = 0;
|
||||
self.native_tick();
|
||||
self.tell_native_now();
|
||||
// Far tiles the samplers asked for (`lod_net`).
|
||||
if let Some(r) = &self.remote {
|
||||
r.session.send_far_terrain();
|
||||
}
|
||||
self.tick_chat_session();
|
||||
// Minecraft.tick sends this even when the player cannot tick yet,
|
||||
// including while waiting for chunks. Keep it outside early returns.
|
||||
@@ -5440,6 +5477,7 @@ impl Game<'_> {
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
self.tick_remote_entities(running);
|
||||
self.tick_random_textures();
|
||||
// Breeze.tick's block dust: drawn by each client for the breezes it sees.
|
||||
if running {
|
||||
self.tick_breeze_effects();
|
||||
@@ -6313,6 +6351,8 @@ impl Game<'_> {
|
||||
collision,
|
||||
false,
|
||||
&self.res.motion().fluids,
|
||||
// The server decides what an item rests on; this copy only moves between updates.
|
||||
&[],
|
||||
);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
@@ -6837,6 +6877,10 @@ impl Game<'_> {
|
||||
if self.hands_busy() {
|
||||
return;
|
||||
}
|
||||
// An item a mod claims: its use is the mod's, and the server never hears of it.
|
||||
if self.mod_use_start() {
|
||||
return;
|
||||
}
|
||||
// Resolve the crosshair once. A passing entity interaction keeps
|
||||
// that target and may only fall back to using the item itself.
|
||||
let entity = self.entity_targeted();
|
||||
@@ -7057,7 +7101,7 @@ impl Game<'_> {
|
||||
self.open_block_editor_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
|
||||
// (a chest, barrel, furnace, …). CONSUME does not swing.
|
||||
if result == InteractionResult::Success {
|
||||
if result.should_swing() {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
|
||||
@@ -7448,6 +7492,11 @@ impl Game<'_> {
|
||||
let Some(held) = self.with_hand(hand, |g| g.held().cloned()) else {
|
||||
return false;
|
||||
};
|
||||
// A mod's claimed item is never used on the server; its own use
|
||||
// starts from the main hand only (`mod_use_start`).
|
||||
if self.native_input.claimant(&held.item).is_some() {
|
||||
return false;
|
||||
}
|
||||
let seq = self.next_sequence();
|
||||
self.send(S::UseItem(play::UseItem {
|
||||
hand: hand_id(hand),
|
||||
@@ -7501,6 +7550,11 @@ impl Game<'_> {
|
||||
}
|
||||
return succeeded;
|
||||
}
|
||||
// SpawnEggItem.use on the client succeeds after a SOURCE_ONLY block
|
||||
// hit; the server alone tests LiquidBlock and the placement permission.
|
||||
if held.item.ends_with("_spawn_egg") {
|
||||
return self.spawn_egg_allowed(&held.item) && self.bucket_hit().is_some();
|
||||
}
|
||||
// Food starts being eaten (Consumable.startConsuming). A food that
|
||||
// can't be eaten (full and not always-edible) fails the use
|
||||
// (InteractionResult.FAIL): startUseItem tries the other hand, and
|
||||
@@ -7713,6 +7767,8 @@ impl Game<'_> {
|
||||
/// stack, its hunger and the sounds.
|
||||
fn remote_tick_using(&mut self) {
|
||||
let Some((slot, ticks)) = self.using else {
|
||||
// A mod's use the game reset (the server stopping the use...).
|
||||
self.end_mod_use();
|
||||
return;
|
||||
};
|
||||
let stack = self.stack_in(slot).cloned();
|
||||
@@ -7724,12 +7780,17 @@ impl Game<'_> {
|
||||
r.use_item = item;
|
||||
} else if r.use_item != item {
|
||||
self.using = None;
|
||||
self.end_mod_use();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
|
||||
// different stack in the hand. The off hand keeps its item.
|
||||
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
|
||||
// A mod's item: held until the button lets go, counted for the mod.
|
||||
if self.mod_use_tick(slot, ticks, !self.right || !in_hand) {
|
||||
return;
|
||||
}
|
||||
// BrushItem.onUseTick: with no block aimed the use ends (the server
|
||||
// ends it too); a stroke shows its dust. Its sounds are the server's.
|
||||
if stack
|
||||
@@ -9476,6 +9537,11 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
self.0.using.is_some()
|
||||
}
|
||||
fn release_using(&mut self) {
|
||||
if let Some((slot, ticks)) = self.0.using
|
||||
&& self.0.mod_use_tick(slot, ticks, true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.0.release_remote_use();
|
||||
}
|
||||
fn start_attack(&mut self) -> bool {
|
||||
|
||||
@@ -143,6 +143,7 @@ impl Game<'_> {
|
||||
if self.title.is_none() {
|
||||
fog.color =
|
||||
self.native_fog_color_now(self.clock.fraction() as f32, at, fog_type, fog.color);
|
||||
fog = self.native_fog_now(self.clock.fraction() as f32, at, fog_type, fog);
|
||||
}
|
||||
fog
|
||||
}
|
||||
@@ -161,6 +162,38 @@ impl Game<'_> {
|
||||
color
|
||||
}
|
||||
}
|
||||
fn native_fog_now(
|
||||
&self,
|
||||
partial: f32,
|
||||
camera: [f64; 3],
|
||||
fog_type: FogType,
|
||||
original: FogData,
|
||||
) -> FogData {
|
||||
let mut fog = original;
|
||||
self.native_render_fog(partial, camera, fog_type, &mut fog);
|
||||
let distances = [
|
||||
fog.environmental_start,
|
||||
fog.environmental_end,
|
||||
fog.render_distance_start,
|
||||
fog.render_distance_end,
|
||||
fog.sky_end,
|
||||
fog.cloud_end,
|
||||
];
|
||||
if fog
|
||||
.color
|
||||
.iter()
|
||||
.all(|v| v.is_finite() && (0.0..=1.0).contains(v))
|
||||
&& distances.iter().all(|v| v.is_finite())
|
||||
&& fog.environmental_end > fog.environmental_start
|
||||
&& fog.render_distance_end > fog.render_distance_start
|
||||
&& fog.sky_end >= 0.0
|
||||
&& fog.cloud_end >= 0.0
|
||||
{
|
||||
fog
|
||||
} else {
|
||||
original
|
||||
}
|
||||
}
|
||||
/// `FogRenderer.computeFogColor`'s night vision brightenFactor:
|
||||
/// `GameRenderer.nightVisionScale`, but not while Darkness is on.
|
||||
fn fog_night_vision(&self) -> f32 {
|
||||
@@ -286,7 +319,7 @@ impl Game<'_> {
|
||||
// drawn on the HUD by draw_spyglass.
|
||||
let fx = &self.screen_fx;
|
||||
let overlays = hud_overlays::CameraOverlays {
|
||||
vignette: self.settings.vignette.then_some((
|
||||
vignette: self.vignette_shown().then_some((
|
||||
fx.vignette_brightness,
|
||||
self.border.warning_strength(p.position.x, p.position.z),
|
||||
)),
|
||||
|
||||
@@ -821,7 +821,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// The host and guests that have entered the world, rebuilt from live state.
|
||||
fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlayer> {
|
||||
pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlayer> {
|
||||
let mut players = vec![me];
|
||||
players.extend(guests.iter().filter(|g| g.placed).map(|g| {
|
||||
let mut player = TabPlayer::new(g.uuid, &g.name);
|
||||
|
||||
@@ -24,6 +24,7 @@ impl Screen {
|
||||
| Self::Advancements
|
||||
| Self::WorldOptions
|
||||
| Self::Friends
|
||||
| Self::Social
|
||||
| Self::ServerLinks
|
||||
| Self::ConfirmLink
|
||||
| Self::DialogWarning
|
||||
@@ -118,8 +119,7 @@ fn pause_buttons(
|
||||
/// The icon row: report bugs, feedback, friends, player reporting.
|
||||
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
|
||||
/// Play with Friends; the first two open their link through
|
||||
/// `ConfirmLinkScreen`; player reporting (`SocialInteractionsScreen`, not
|
||||
/// ported) stays inactive.
|
||||
/// `ConfirmLinkScreen`; player reporting opens `SocialInteractionsScreen`.
|
||||
const FRIENDS_ICON: usize = 2;
|
||||
/// `CommonLinks.SNAPSHOT_BUGS_FEEDBACK`, behind `PauseScreen.REPORT_BUGS`.
|
||||
const BUGS_LINK: &str = "https://aka.ms/snapshotbugs?ref=game";
|
||||
@@ -143,7 +143,7 @@ const ICON_LABELS: [&str; 4] = [
|
||||
/// button always, friends when the friends list is shown.
|
||||
fn icon_active(icon: usize, friends: bool) -> bool {
|
||||
match icon {
|
||||
0 | 1 => true,
|
||||
0 | 1 | 3 => true,
|
||||
FRIENDS_ICON => friends,
|
||||
_ => false,
|
||||
}
|
||||
@@ -362,6 +362,8 @@ impl Game<'_> {
|
||||
// ConfirmLinkScreen.confirmLink(this, CommonLinks...) on the pause screen.
|
||||
12 => self.confirm_trusted_link(BUGS_LINK, Screen::Paused),
|
||||
13 => self.confirm_trusted_link(feedback_link(), Screen::Paused),
|
||||
// PauseScreen: `new SocialInteractionsScreen(this)`.
|
||||
15 => self.open_social(true),
|
||||
30 => self.open_split_settings(),
|
||||
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
|
||||
// additions dialog, from the pause menu.
|
||||
@@ -403,8 +405,9 @@ impl Game<'_> {
|
||||
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
|
||||
.map(|b| b.4)
|
||||
.or_else(|| {
|
||||
// The report bugs and feedback icons (ids 12 and 13).
|
||||
(0..2)
|
||||
// The report bugs, feedback and player reporting icons (ids 12, 13 and 15).
|
||||
[0, 1, 3]
|
||||
.into_iter()
|
||||
.find(|&i| ui::hovered(self.cursor, icons[i].0, icons[i].1, 20.0, 20.0))
|
||||
.map(|i| 12 + i as u8)
|
||||
})
|
||||
@@ -916,16 +919,15 @@ mod tests {
|
||||
fn pause_icons_report_bugs_and_feedback_are_active_like_vanilla() {
|
||||
use super::{FRIENDS_ICON, icon_active};
|
||||
// PauseScreen.createPauseMenu: the bug and feedback buttons are live,
|
||||
// friends follows its list, player reporting waits for its screen.
|
||||
assert!(icon_active(0, false) && icon_active(1, false));
|
||||
// friends follows its list, player reporting opens the social screen.
|
||||
assert!(icon_active(0, false) && icon_active(1, false) && icon_active(3, false));
|
||||
assert!(icon_active(FRIENDS_ICON, true) && !icon_active(FRIENDS_ICON, false));
|
||||
assert!(!icon_active(3, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pause_keyboard_focus_includes_the_active_icon_buttons() {
|
||||
assert_eq!(pause_icon_focus_ids(false), [12, 13]);
|
||||
assert_eq!(pause_icon_focus_ids(true), [12, 13, 10]);
|
||||
assert_eq!(pause_icon_focus_ids(false), [12, 13, 15]);
|
||||
assert_eq!(pause_icon_focus_ids(true), [12, 13, 10, 15]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -136,6 +136,8 @@ pub(super) struct PackIds {
|
||||
missing_item: i32,
|
||||
/// `oldHandLight` (3.2): the first held item and light are those of the brighter hand.
|
||||
old_hand_light: bool,
|
||||
/// `vignette`: `false` hides the game's vignette over the pack's image.
|
||||
vignette: bool,
|
||||
/// `oldLighting`: `true` keeps the game's face shading in the block colours.
|
||||
old_lighting: bool,
|
||||
}
|
||||
@@ -235,6 +237,10 @@ impl Game<'_> {
|
||||
old_hand_light: properties
|
||||
.get("oldHandLight")
|
||||
.is_none_or(|v| v.trim() != "false"),
|
||||
// On unless the pack turns it off (`vignette=false`).
|
||||
vignette: properties
|
||||
.get("vignette")
|
||||
.is_none_or(|v| v.trim() != "false"),
|
||||
// Off unless the pack turns it on (Iris shades no faces by default).
|
||||
old_lighting: properties
|
||||
.get("oldLighting")
|
||||
@@ -369,6 +375,11 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
|
||||
/// Whether the game's vignette draws: the option, unless the loaded pack turns it off.
|
||||
pub(super) fn vignette_shown(&self) -> bool {
|
||||
self.settings.vignette && self.pack_ids.as_ref().is_none_or(|ids| ids.vignette)
|
||||
}
|
||||
|
||||
/// `heldItemId(2)` and `heldBlockLightValue(2)`: main hand, then off hand (5.2, 5.3).
|
||||
fn held_for_pack(&mut self) -> ([i32; 2], [i32; 2]) {
|
||||
if self.pack_ids.is_none() {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! `SocialInteractionsScreen` and `PlayerSocialManager` on the game side: the players Horizon
|
||||
//! knows (the tab list's), the ones whose chat the player hid, the screen's page, search and
|
||||
//! scroll, and what hiding does to chat (`Minecraft.isBlocked`). The screen is
|
||||
//! `social_screen`. Blocking is Mojang's service (`UserApiService.isBlockedPlayer`), which
|
||||
//! Horizon does not offer: nobody is blocked, as in vanilla's offline mode.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
social_screen::{self, Action, Entry, Page, SocialView},
|
||||
tab_list::TabPlayer,
|
||||
text::Text,
|
||||
ui::EditBox,
|
||||
};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
};
|
||||
|
||||
/// `PlayerSocialManager.hiddenPlayers` and the screen's state.
|
||||
pub(super) struct Social {
|
||||
pub hidden: HashSet<u128>,
|
||||
/// `discoveredNamesToUUID`, the other way: the names of the players seen, so a hidden player
|
||||
/// who left is still listed by name.
|
||||
names: HashMap<u128, String>,
|
||||
page: Page,
|
||||
search: EditBox,
|
||||
scroll: f32,
|
||||
/// Opened from the pause menu: Done goes back there.
|
||||
from_pause: bool,
|
||||
}
|
||||
|
||||
impl Default for Social {
|
||||
fn default() -> Self {
|
||||
let mut search = EditBox::new(0.0, 0.0, 200.0, 15.0);
|
||||
search.max_length = 16;
|
||||
Self {
|
||||
hidden: HashSet::new(),
|
||||
names: HashMap::new(),
|
||||
page: Page::All,
|
||||
search,
|
||||
scroll: 0.0,
|
||||
from_pause: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players of the level, as the tab list shows them, and the local player's id.
|
||||
fn social_players(&mut self) -> (Vec<TabPlayer>, u128) {
|
||||
match self.remote_tab_players() {
|
||||
Some((players, _, _)) => {
|
||||
let me = self.remote.as_ref().map_or(0, |r| r.profile());
|
||||
(players, me)
|
||||
}
|
||||
None => {
|
||||
let uuid = self.level.player_uuid.map_or(0, |u| {
|
||||
u.iter()
|
||||
.fold(0u128, |a, &w| (a << 32) | u128::from(w as u32))
|
||||
});
|
||||
let mut me = TabPlayer::new(uuid, super::chat::PLAYER_NAME);
|
||||
me.skin = "entity/player/wide/steve".into();
|
||||
let guests = self.lan.as_ref().map_or(&[][..], |h| &h.hub.guests);
|
||||
(super::scoreboard::local_tab_players(me, guests), uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Minecraft.isBlocked`: whether a player's chat is hidden (never the player's own).
|
||||
pub(super) fn social_hides(&mut self, id: u128) -> bool {
|
||||
if !self.social.hidden.contains(&id) {
|
||||
return false;
|
||||
}
|
||||
let (_, me) = self.social_players();
|
||||
id != me
|
||||
}
|
||||
/// `new SocialInteractionsScreen(lastScreen)`: it opens on All, with an empty search.
|
||||
pub(super) fn open_social(&mut self, from_pause: bool) {
|
||||
self.social.page = Page::All;
|
||||
self.social.search.set_value("");
|
||||
self.social.scroll = 0.0;
|
||||
self.social.from_pause = from_pause;
|
||||
self.screen = Screen::Social;
|
||||
self.capture(false);
|
||||
}
|
||||
pub(super) fn close_social(&mut self) {
|
||||
if self.social.from_pause {
|
||||
self.screen = Screen::Paused;
|
||||
} else {
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
}
|
||||
/// The page's rows, sorted and filtered as `SocialInteractionsPlayerList` does.
|
||||
fn social_view(&mut self) -> SocialView {
|
||||
let (players, me) = self.social_players();
|
||||
for p in &players {
|
||||
self.social.names.insert(p.uuid, p.name.clone());
|
||||
}
|
||||
let hidden = self.social.hidden.clone();
|
||||
let search = self.social.search.value.to_lowercase();
|
||||
let mut entries: Vec<Entry> = Vec::new();
|
||||
let online = |id: u128| players.iter().find(|p| p.uuid == id);
|
||||
let page = self.social.page;
|
||||
let ids: Vec<u128> = match page {
|
||||
Page::All => players.iter().map(|p| p.uuid).collect(),
|
||||
Page::Hidden => hidden.iter().copied().collect(),
|
||||
// `UserApiService.isBlockedPlayer`: nobody, without the service.
|
||||
Page::Blocked => Vec::new(),
|
||||
};
|
||||
for id in ids {
|
||||
let (name, skin, removed) = match online(id) {
|
||||
Some(p) => (p.name.clone(), p.skin.clone(), false),
|
||||
None => (
|
||||
self.social.names.get(&id).cloned().unwrap_or_default(),
|
||||
horizon_client::tab_list::default_skin(id).to_owned(),
|
||||
true,
|
||||
),
|
||||
};
|
||||
if !search.is_empty() && !name.to_lowercase().contains(&search) {
|
||||
continue;
|
||||
}
|
||||
let skin = self.face_skin(id, &skin);
|
||||
entries.push(Entry {
|
||||
id,
|
||||
name,
|
||||
skin,
|
||||
hidden: hidden.contains(&id),
|
||||
removed,
|
||||
local: id == me,
|
||||
});
|
||||
}
|
||||
social_screen::sort_entries(&mut entries);
|
||||
let count = players.len();
|
||||
let server = self.social_server_name();
|
||||
let key = if count > 1 {
|
||||
"gui.socialInteractions.server_label.multiple"
|
||||
} else {
|
||||
"gui.socialInteractions.server_label.single"
|
||||
};
|
||||
let args = [Text::literal(server), Text::literal(count.to_string())];
|
||||
let max = social_screen::max_scroll(self.gui_size().1, entries.len());
|
||||
self.social.scroll = self.social.scroll.clamp(0.0, max);
|
||||
SocialView {
|
||||
page,
|
||||
search: self.social.search.value.clone(),
|
||||
entries,
|
||||
server_label: Text::translate(key, &args),
|
||||
scroll: self.social.scroll,
|
||||
}
|
||||
}
|
||||
/// The server's name: a local world's name, a server's entry in the list.
|
||||
fn social_server_name(&self) -> String {
|
||||
match (&self.integrated, self.server_address()) {
|
||||
(Some(server), _) => server.world_name.clone(),
|
||||
(None, Some(address)) if self.remote.is_some() => address.to_owned(),
|
||||
_ => self.level.name.clone(),
|
||||
}
|
||||
}
|
||||
pub(super) fn draw_social_screen(&mut self, batch: &mut horizon_client::hud::HudBatch) {
|
||||
self.flush_skin_slots();
|
||||
let view = self.social_view();
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
horizon_client::widgets::ScreenInfo::new("social", social_screen::TITLE)
|
||||
});
|
||||
social_screen::draw(
|
||||
batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
&view,
|
||||
&mut self.social.search,
|
||||
true,
|
||||
);
|
||||
}
|
||||
pub(super) fn social_click(&mut self) {
|
||||
let view = self.social_view();
|
||||
let Some(action) = social_screen::click(self.gui_size(), self.cursor, &view) else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
match action {
|
||||
Action::Page(page) => {
|
||||
self.social.page = page;
|
||||
self.social.scroll = 0.0;
|
||||
}
|
||||
Action::Hide(id) | Action::Show(id) => {
|
||||
let hiding = matches!(action, Action::Hide(_));
|
||||
let name = self.social.names.get(&id).cloned().unwrap_or_default();
|
||||
if hiding {
|
||||
self.social.hidden.insert(id);
|
||||
} else {
|
||||
self.social.hidden.remove(&id);
|
||||
}
|
||||
// `PlayerEntry.onHiddenOrShown`: a line in the chat, and the narrator says it.
|
||||
let key = if hiding {
|
||||
"gui.socialInteractions.hidden_in_chat"
|
||||
} else {
|
||||
"gui.socialInteractions.shown_in_chat"
|
||||
};
|
||||
let line = Text::translate(key, &[Text::literal(name)]);
|
||||
self.say_system_now(&line);
|
||||
self.system_message(line);
|
||||
}
|
||||
Action::Done => self.close_social(),
|
||||
}
|
||||
}
|
||||
/// `AbstractSelectionList.mouseScrolled`: a row's height and a bit a notch.
|
||||
pub(super) fn social_scroll(&mut self, delta: f32) {
|
||||
self.social.scroll -= delta * 18.0;
|
||||
// `social_view` clamps it to the list.
|
||||
let _ = self.social_view();
|
||||
}
|
||||
/// The search box has the focus from the start (`setInitialFocus`): typing fills it.
|
||||
pub(super) fn social_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
}
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
match key {
|
||||
KeyCode::Escape => self.close_social(),
|
||||
_ => {
|
||||
// `EditBox.keyPressed`: Backspace, Delete, the arrows, Home and End.
|
||||
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
|
||||
if !self.social.search.key(key, ctrl, shift)
|
||||
&& self.types_text()
|
||||
&& let Some(text) = event.text.as_deref()
|
||||
{
|
||||
self.social_type(text);
|
||||
}
|
||||
self.social.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn social_backspace(&mut self) {
|
||||
self.social.search.key(KeyCode::Backspace, false, false);
|
||||
self.social.scroll = 0.0;
|
||||
}
|
||||
/// Characters into the search box (`searchBox.setMaxLength(16)`).
|
||||
pub(super) fn social_type(&mut self, text: &str) {
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
self.social.search.insert_text(&text);
|
||||
self.social.scroll = 0.0;
|
||||
}
|
||||
}
|
||||
@@ -284,6 +284,7 @@ impl<'a> Split<'a> {
|
||||
for button in std::mem::take(&mut game.physical_buttons) {
|
||||
game.extras_mouse(button, false);
|
||||
}
|
||||
game.drag_button = None;
|
||||
game.modifiers = Default::default();
|
||||
game.left = false;
|
||||
game.right = false;
|
||||
|
||||
@@ -629,6 +629,12 @@ impl Game<'_> {
|
||||
};
|
||||
self.thrower_hit(t.guest, &victim, direct, damage);
|
||||
}
|
||||
// Projectile.onHit: the landing is heard, at the hit.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(Who::Player),
|
||||
[t.position.x, t.position.y, t.position.z],
|
||||
);
|
||||
self.audio.play(
|
||||
"minecraft:item.trident.hit",
|
||||
Some([t.position.x, t.position.y, t.position.z]),
|
||||
@@ -647,9 +653,20 @@ impl Game<'_> {
|
||||
// `AbstractArrow.onHitBlock` on a hit.
|
||||
let from = t.position;
|
||||
let flight = t.velocity;
|
||||
if let Some((stop, _)) =
|
||||
if let Some((stop, block)) =
|
||||
clip_block_stop(&self.world, self.res.facts(), self.collision, from, flight)
|
||||
{
|
||||
// Projectile.onHit on a block: PROJECTILE_LAND at the
|
||||
// block's centre.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(Who::Player),
|
||||
[
|
||||
f64::from(block.x) + 0.5,
|
||||
f64::from(block.y) + 0.5,
|
||||
f64::from(block.z) + 0.5,
|
||||
],
|
||||
);
|
||||
if t.stack.enchantment_level("minecraft:channeling") > 0 {
|
||||
self.channel_rod_hit(from, flight, t.guest);
|
||||
}
|
||||
|
||||
@@ -52,10 +52,43 @@ impl WorldRequest {
|
||||
// Before any chunk is read: a loaded container with a loot table is rolled as its chunk
|
||||
// is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world
|
||||
// opens. Every world opens here: in-frame, on the server thread, on a dedicated server.
|
||||
horizon_client::structure_blocks::loot_data(
|
||||
&options.resources,
|
||||
&horizon_world::loot::PackOverlay::NONE,
|
||||
);
|
||||
let loot = || {
|
||||
horizon_client::structure_blocks::loot_data(
|
||||
&options.resources,
|
||||
&horizon_world::loot::PackOverlay::NONE,
|
||||
);
|
||||
};
|
||||
// Bone meal's and saplings' features, over the plain worldgen bundle.
|
||||
let live_features = || growth::install_live_features(res, pack, options);
|
||||
if res.threads() <= 1 {
|
||||
loot();
|
||||
let prepared = self.prepare_world(res, pack, options, settings, upgrade)?;
|
||||
live_features();
|
||||
return Ok(prepared);
|
||||
}
|
||||
// Neither needs the world: they load beside it (the generator's decoration keeps one
|
||||
// core busy) and are done before it opens. A bundle both read loads once.
|
||||
std::thread::scope(|scope| {
|
||||
let loot = scope.spawn(loot);
|
||||
let live_features = scope.spawn(live_features);
|
||||
let prepared = self.prepare_world(res, pack, options, settings, upgrade);
|
||||
for helper in [loot, live_features] {
|
||||
helper
|
||||
.join()
|
||||
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
|
||||
}
|
||||
prepared
|
||||
})
|
||||
}
|
||||
|
||||
fn prepare_world(
|
||||
self,
|
||||
res: &Resources,
|
||||
pack: &PackResources,
|
||||
options: &Options,
|
||||
settings: &Settings,
|
||||
upgrade: &horizon_world::datafix::progress::UpgradeProgress,
|
||||
) -> Result<PreparedWorld> {
|
||||
let Self {
|
||||
save_root,
|
||||
folder,
|
||||
@@ -227,8 +260,6 @@ impl WorldRequest {
|
||||
};
|
||||
}
|
||||
|
||||
growth::install_live_features(res, pack, options);
|
||||
|
||||
// Persist new metadata on the worker too, before publishing the world.
|
||||
if !save_root.join("level.dat").exists() {
|
||||
level.write(&save_root)?;
|
||||
@@ -788,6 +819,9 @@ pub(super) fn make_streaming(
|
||||
streaming.biome_blend = settings.biome_blend;
|
||||
streaming.smooth_lighting = settings.smooth_lighting;
|
||||
streaming.cutout_leaves = settings.cutout_leaves;
|
||||
streaming.leaf_culling = settings
|
||||
.horizon
|
||||
.leaf_culling_effective(&settings.resource_packs);
|
||||
streaming.biome_zoom = BiomeZoom::new(obfuscate_seed(seed));
|
||||
streaming.sky_light = dim != Dim::Nether;
|
||||
streaming.nether_shading = dim == Dim::Nether;
|
||||
|
||||
@@ -157,7 +157,7 @@ impl Hub {
|
||||
};
|
||||
if ac.config.runs(Check::Prediction) {
|
||||
let g = &mut self.guests[i];
|
||||
let (input, sprinting) = (g.input, g.sprinting);
|
||||
let (input, sprinting) = (g.input, g.anticheat.sprinting);
|
||||
g.anticheat
|
||||
.record
|
||||
.get_or_insert_default()
|
||||
|
||||
@@ -939,6 +939,11 @@ impl Hub {
|
||||
}));
|
||||
// PlayerList.placeNewPlayer: ClientboundUpdateRecipesPacket.
|
||||
g.send(&raw(ids::UPDATE_RECIPES, self.sync.recipes.clone()));
|
||||
// OnDatapackSyncEvent.sendRecipes: the recipes the native mods asked
|
||||
// for, to a Horizon client only.
|
||||
if g.brand == "horizon" {
|
||||
send_recipe_content(g);
|
||||
}
|
||||
// PlayerList.sendPlayerPermissionLevel, then the recipe book.
|
||||
self.send_permission_level(i);
|
||||
self.send_initial_recipe_book(i);
|
||||
@@ -2553,6 +2558,9 @@ impl Hub {
|
||||
continue;
|
||||
}
|
||||
self.guests[i].send(&raw(ids::UPDATE_RECIPES, sync.recipes.clone()));
|
||||
if self.guests[i].brand == "horizon" {
|
||||
send_recipe_content(&self.guests[i]);
|
||||
}
|
||||
self.send_initial_recipe_book(i);
|
||||
}
|
||||
}
|
||||
@@ -2651,6 +2659,7 @@ impl Hub {
|
||||
fn set_sprinting(&mut self, i: usize, sprinting: bool) {
|
||||
let g = &mut self.guests[i];
|
||||
g.sprinting = sprinting;
|
||||
g.anticheat.sprinting = sprinting;
|
||||
if let Some(m) = g.motion.as_mut() {
|
||||
let _ = m.set_sprint_modifier(sprinting);
|
||||
}
|
||||
@@ -3419,6 +3428,10 @@ impl Hub {
|
||||
spawn: info,
|
||||
keep: 0,
|
||||
}));
|
||||
// ClientPacketListener.handleRespawn without KEEP_ENTITY_DATA: the client's new player
|
||||
// starts with wasSprinting false. (A level change keeps it: the new player sprints on, or
|
||||
// says STOP.)
|
||||
g.anticheat.sprinting = false;
|
||||
// The respawned player is a new one, without effects; so is the
|
||||
// client's (ClientPacketListener.handleRespawn).
|
||||
g.effects = Default::default();
|
||||
@@ -3635,6 +3648,19 @@ fn invalid_movement() -> Tag {
|
||||
translatable("multiplayer.disconnect.invalid_player_movement", &[])
|
||||
}
|
||||
|
||||
/// The recipe content the native mods asked for (`lan::recipes::content`),
|
||||
/// to one Horizon client: none when no mod asked.
|
||||
fn send_recipe_content(g: &Guest) {
|
||||
for data in super::recipes::content().iter() {
|
||||
g.send(&C::CustomPayload(
|
||||
horizon_protocol::packets::configuration::CustomPayload {
|
||||
channel: super::recipes::CONTENT_CHANNEL.into(),
|
||||
data: data.clone(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn raw(id: i32, payload: Vec<u8>) -> C {
|
||||
C::Unknown(id, payload)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub mod hub;
|
||||
pub mod menu;
|
||||
pub mod playerdata;
|
||||
pub mod prediction;
|
||||
mod recipes;
|
||||
pub mod recipes;
|
||||
pub mod scoreboard;
|
||||
pub mod tracking;
|
||||
pub mod waypoints;
|
||||
|
||||
@@ -196,6 +196,10 @@ pub struct Replay {
|
||||
/// Ticks the client's jump delay is unknown: under an excuse the replay may have jumped where the
|
||||
/// client couldn't (on a block it hadn't received), or the other way round.
|
||||
jump_unsure: u8,
|
||||
/// The last tick played: whether the client said it sprinted, and whether its keys pushed it
|
||||
/// forward (for [`Replay::sprints`]).
|
||||
claimed: bool,
|
||||
forward: bool,
|
||||
/// Ticks in a row within the tolerance, at most [`STEADY`].
|
||||
steady: u8,
|
||||
/// The velocity the client's last tick left it with, less the replay's.
|
||||
@@ -230,6 +234,8 @@ impl Replay {
|
||||
used: server.item_use_speed,
|
||||
use_window: 0,
|
||||
jump_unsure: 0,
|
||||
claimed: false,
|
||||
forward: false,
|
||||
steady: STEADY,
|
||||
missed: Vec3::ZERO,
|
||||
meeting: None,
|
||||
@@ -419,7 +425,7 @@ impl Replay {
|
||||
}
|
||||
// ClientPacketListener.handleSetEntityData: the flag alone. A sprint the tick goes on
|
||||
// with is then started again (`MotionWorld::replayed_tick`), its modifier with it, as
|
||||
// LocalPlayer.aiStep does when the sprint key is held.
|
||||
// LocalPlayer.aiStep does when the sprint key is held ([`Replay::sprints`]).
|
||||
Change::Sprinting(sprinting) => body.sprinting = sprinting,
|
||||
Change::Effects => body.status = server.status.clone(),
|
||||
// `sync` takes them every tick.
|
||||
@@ -427,6 +433,18 @@ impl Replay {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the client sprints in `tick`: as it said (ServerboundPlayerCommandPacket), but for a
|
||||
/// sprint the server stopped (its flag, [`Change::Sprinting`]) that the client says goes on. That
|
||||
/// client never said STOP: its LocalPlayer.aiStep started the sprint again, which takes the sprint
|
||||
/// key held, or forward pressed again (a double tap). Without either it would have stopped and
|
||||
/// said so: one that sprints on is played walking, until it holds the key or says STOP.
|
||||
fn sprints(&mut self, tick: &ClientTick, keys: Keys) -> bool {
|
||||
let restarted = tick.sprinting && self.claimed && !self.body.sprinting;
|
||||
self.claimed = tick.sprinting;
|
||||
let forward = std::mem::replace(&mut self.forward, keys.direction()[1] > 1.0e-5);
|
||||
tick.sprinting && (!restarted || keys.sprint || !forward)
|
||||
}
|
||||
|
||||
/// Plays `tick` again. `server` is the server's copy of the player, for what the server decides
|
||||
/// (abilities, effects, attributes); `run` moves a body one client tick with these keys and
|
||||
/// sprinting (`MotionWorld::replayed_tick`). None when no candidate could be played.
|
||||
@@ -458,6 +476,7 @@ impl Replay {
|
||||
|| self.use_window > 0 && server.item_use_speed.is_none() && used.is_some();
|
||||
let said = tick.position.map(|p| Vec3::new(p[0], p[1], p[2]));
|
||||
let keys = keys(tick.input);
|
||||
let sprinting = self.sprints(tick, keys);
|
||||
let last = self.said;
|
||||
let offset = |body: &PlayerMotion| match said {
|
||||
Some(p) => body.position.distance(p),
|
||||
@@ -475,7 +494,7 @@ impl Replay {
|
||||
};
|
||||
let (offset, candidate) = if ![1, 2, 4, 8].into_iter().any(unknown) {
|
||||
// Nothing to try (most ticks): the body itself plays the tick.
|
||||
run(&mut self.body, keys, tick.sprinting).ok()?;
|
||||
run(&mut self.body, keys, sprinting).ok()?;
|
||||
(offset(&self.body), 0)
|
||||
} else {
|
||||
let mut best: Option<(f64, u8, Box<PlayerMotion>)> = None;
|
||||
@@ -495,7 +514,7 @@ impl Replay {
|
||||
if let Some(met) = meeting.filter(|_| candidate & 8 != 0) {
|
||||
body.velocity = body.velocity.add_vector(met);
|
||||
}
|
||||
if run(&mut body, keys, tick.sprinting).is_err() {
|
||||
if run(&mut body, keys, sprinting).is_err() {
|
||||
continue;
|
||||
}
|
||||
let off = offset(&body);
|
||||
@@ -704,6 +723,9 @@ mod tests {
|
||||
/// sprint `.1`: the shared flags with that sprinting bit, and the movement speed with the
|
||||
/// sprint modifier or without ([`Change::Sprinting`], [`Change::Attributes`]).
|
||||
server_sprint: Vec<(usize, bool)>,
|
||||
/// The client ignores the sprinting flag and movement speed the server sends (a cheat that
|
||||
/// keeps its sprint through a hit).
|
||||
keep_sprint: bool,
|
||||
/// The first ticks the client is on its loading screen: it ends them without moving or
|
||||
/// sending a move.
|
||||
frozen: usize,
|
||||
@@ -724,6 +746,7 @@ mod tests {
|
||||
blocks: Vec::new(),
|
||||
attributes: None,
|
||||
server_sprint: Vec::new(),
|
||||
keep_sprint: false,
|
||||
frozen: 0,
|
||||
item_use: None,
|
||||
}
|
||||
@@ -832,8 +855,10 @@ mod tests {
|
||||
sprint: Some(sprint),
|
||||
},
|
||||
);
|
||||
client.sprinting = sprint;
|
||||
client.set_sprint_modifier(sprint).unwrap();
|
||||
if !run.keep_sprint {
|
||||
client.sprinting = sprint;
|
||||
client.set_sprint_modifier(sprint).unwrap();
|
||||
}
|
||||
}
|
||||
record.pong(tick, tick);
|
||||
let before = client.position;
|
||||
@@ -1273,6 +1298,98 @@ mod tests {
|
||||
assert!(counted.len() >= 10, "{} of 18: {counted:?}", counted.len());
|
||||
}
|
||||
|
||||
/// A sprint the server stopped after a hit (Player.causeExtraKnockback), which the client's own
|
||||
/// attack on a mob doesn't: the server's flag and movement speed reach the client on tick 14.
|
||||
/// Forward is held throughout, the sprint key for the first 10 ticks, and for the rest if `key`.
|
||||
/// The client's own commands say what it does: with the key it starts the sprint again in its
|
||||
/// next aiStep and says nothing (the live server's `ground, keys forward sprint` flags, where
|
||||
/// the record took the server's player and played the rest of the sprint as a walk); without it
|
||||
/// it stops and says STOP. `water`: the same swimming across a pool, toward its surface.
|
||||
fn sprint_hit(key: bool, water: bool) -> Run {
|
||||
let sprint = if key { 64 } else { 0 };
|
||||
let mut script = vec![(1 | 64, 0.0); 10];
|
||||
script.extend(vec![(1 | sprint, 0.0); 40]);
|
||||
let mut blocks = Vec::new();
|
||||
if water {
|
||||
for x in 14..=26 {
|
||||
for y in 5..=7 {
|
||||
for z in 14..=40 {
|
||||
blocks.push(([x, y, z], "minecraft:water"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Run {
|
||||
script,
|
||||
pitch: if water { -8.0 } else { 0.0 },
|
||||
blocks,
|
||||
server_sprint: vec![(14, false)],
|
||||
..Run::walk()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sprint_the_server_stopped_after_a_hit_is_the_clients() {
|
||||
for (key, water) in [(true, false), (false, false), (true, true), (false, true)] {
|
||||
let Some(steps) = steps_in(sprint_hit(key, water), |_, _, honest| honest, None) else {
|
||||
return;
|
||||
};
|
||||
for (i, s) in steps.iter().enumerate() {
|
||||
assert!(
|
||||
s.played.offset <= EXACT && s.over == Over::default(),
|
||||
"key {key}, water {water}, tick {i}: {:?}",
|
||||
s.played
|
||||
);
|
||||
}
|
||||
// The client sprints on after the hit only with the key.
|
||||
let last = steps.last().unwrap().played.state;
|
||||
assert_eq!(
|
||||
last & Played::SPRINTING != 0,
|
||||
key,
|
||||
"water {water}: {last:b}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A client that sprints on through the server's stop without the sprint key, which vanilla's
|
||||
/// starts again only with it, is played walking from the tick the stop reaches it; one that
|
||||
/// holds the key and moves a twentieth of a block a tick too far counts from there too.
|
||||
#[test]
|
||||
fn a_sprint_kept_without_the_key_or_sped_up_after_a_hit_counts() {
|
||||
for water in [false, true] {
|
||||
let kept = Run {
|
||||
keep_sprint: true,
|
||||
..sprint_hit(false, water)
|
||||
};
|
||||
let fast = |i: usize, _, honest: Vec3| {
|
||||
honest.add_vector(Vec3::new(0.0, 0.0, if i >= 14 { 0.05 } else { 0.0 }))
|
||||
};
|
||||
for (name, steps) in [
|
||||
("kept", steps_in(kept, |_, _, honest| honest, None)),
|
||||
("fast", steps_in(sprint_hit(true, water), fast, None)),
|
||||
] {
|
||||
let Some(steps) = steps else {
|
||||
return;
|
||||
};
|
||||
let counted: Vec<usize> = steps
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.over.ticks > 0)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
assert!(
|
||||
counted.first().is_some_and(|&i| (14..=16).contains(&i)),
|
||||
"{name}, water {water}: {counted:?}"
|
||||
);
|
||||
assert!(
|
||||
counted.len() >= 25,
|
||||
"{name}, water {water}: {} of 36: {counted:?}",
|
||||
counted.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An elytra: a jump while falling starts a glide, played again exactly.
|
||||
#[test]
|
||||
fn a_glide_is_played_again_exactly() {
|
||||
|
||||
@@ -8,6 +8,7 @@ use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// RecipeManager.RECIPE_PROPERTY_SETS: each set and the ingredients it takes
|
||||
@@ -170,3 +171,185 @@ pub fn update_recipes(
|
||||
}
|
||||
w.data
|
||||
}
|
||||
|
||||
/// The Horizon channel a server sends its clients the recipes its native mods
|
||||
/// asked for (`native_datapack_sync`), NeoForge's
|
||||
/// `OnDatapackSyncEvent.sendRecipes`. Horizon clients only.
|
||||
pub const CONTENT_CHANNEL: &str = "horizon:recipe_content";
|
||||
/// `ClientboundCustomPayloadPacket`'s cap on a payload, less room for the
|
||||
/// envelope.
|
||||
const CONTENT_MAX: usize = 1_048_576 - 1024;
|
||||
|
||||
/// One payload of the recipe content: some recipes as `(id, json)`, and on the
|
||||
/// last one the recipe types they replace on the client (a type the server has
|
||||
/// no recipe of still empties the client's).
|
||||
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ContentPart {
|
||||
pub recipes: Vec<(String, Value)>,
|
||||
pub last: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// The payloads for `types`' recipes among `sources` (`Recipes::sources`,
|
||||
/// vanilla's ids bare), ids made whole (`minecraft:<name>`). With no type
|
||||
/// asked for: nothing, or with `reset` (this server sent recipes before) one
|
||||
/// last part with no types, which puts its clients back on their own. A
|
||||
/// recipe too big for a payload of its own is left out.
|
||||
pub fn encode_content(types: &[String], sources: &[(String, Value)], reset: bool) -> Vec<Vec<u8>> {
|
||||
// The last part carries the types: they count against its cap too.
|
||||
let types_len = serde_json::to_string(types).map_or(0, |t| t.len()) + 32;
|
||||
if types_len > CONTENT_MAX {
|
||||
log::warn!("Horizon: too many recipe types to send to clients");
|
||||
return Vec::new();
|
||||
}
|
||||
if types.is_empty() && !reset {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut part: Vec<(String, Value)> = Vec::new();
|
||||
let mut size = 0;
|
||||
for (name, v) in sources {
|
||||
if !types.iter().any(|t| v["type"].as_str() == Some(t)) {
|
||||
continue;
|
||||
}
|
||||
let id = if name.contains(':') {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
let len = id.len() + v.to_string().len() + 8;
|
||||
if len > CONTENT_MAX {
|
||||
log::warn!("Horizon: recipe {id} is too big to send to clients");
|
||||
continue;
|
||||
}
|
||||
if size + len > CONTENT_MAX {
|
||||
let recipes = std::mem::take(&mut part);
|
||||
out.push(
|
||||
serde_json::to_vec(&ContentPart {
|
||||
recipes,
|
||||
last: None,
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
size = 0;
|
||||
}
|
||||
size += len;
|
||||
part.push((id, v.clone()));
|
||||
}
|
||||
if size + types_len > CONTENT_MAX {
|
||||
let recipes = std::mem::take(&mut part);
|
||||
out.push(
|
||||
serde_json::to_vec(&ContentPart {
|
||||
recipes,
|
||||
last: None,
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
let last = ContentPart {
|
||||
recipes: part,
|
||||
last: Some(types.to_vec()),
|
||||
};
|
||||
out.push(serde_json::to_vec(&last).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
/// What the server sends its Horizon clients now (`set_content`).
|
||||
static CONTENT: Mutex<Option<Arc<Vec<Vec<u8>>>>> = Mutex::new(None);
|
||||
|
||||
/// The recipe content the server sends from now on, at a join and after a
|
||||
/// `/reload` (one server per process, as `lan::sync_data`).
|
||||
pub fn set_content(payloads: Vec<Vec<u8>>) {
|
||||
if !payloads.is_empty() {
|
||||
SENT.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
*CONTENT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(payloads));
|
||||
}
|
||||
|
||||
/// Whether this server sent its clients recipes before: a later load that
|
||||
/// asks for none must put them back on their own (`encode_content`'s reset).
|
||||
static SENT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
pub fn sent_before() -> bool {
|
||||
SENT.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The payloads to send a Horizon client; none when no mod asked.
|
||||
pub fn content() -> Arc<Vec<Vec<u8>>> {
|
||||
CONTENT
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod content_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn content_keeps_the_asked_types_and_ends_with_them() {
|
||||
let sources = vec![
|
||||
("furnace".to_owned(), json!({"type": "minecraft:smelting"})),
|
||||
(
|
||||
"stick".to_owned(),
|
||||
json!({"type": "minecraft:crafting_shaped"}),
|
||||
),
|
||||
("mymod:crush".to_owned(), json!({"type": "mymod:crusher"})),
|
||||
];
|
||||
assert!(encode_content(&[], &sources, false).is_empty());
|
||||
// After a sync that sent recipes, asking for none sends an empty last part.
|
||||
let reset = encode_content(&[], &sources, true);
|
||||
assert_eq!(reset.len(), 1);
|
||||
let part: ContentPart = serde_json::from_slice(&reset[0]).unwrap();
|
||||
assert!(part.recipes.is_empty());
|
||||
assert_eq!(part.last.as_deref(), Some(&[][..]));
|
||||
let types = vec![
|
||||
"minecraft:smelting".to_owned(),
|
||||
"mymod:crusher".to_owned(),
|
||||
"x:none".to_owned(),
|
||||
];
|
||||
let parts = encode_content(&types, &sources, false);
|
||||
assert_eq!(parts.len(), 1);
|
||||
let part: ContentPart = serde_json::from_slice(&parts[0]).unwrap();
|
||||
let ids: Vec<&str> = part.recipes.iter().map(|(id, _)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["minecraft:furnace", "mymod:crush"]);
|
||||
assert_eq!(part.last.as_deref(), Some(types.as_slice()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_splits_under_the_payload_cap() {
|
||||
let big = "x".repeat(300_000);
|
||||
let sources: Vec<_> = (0..10)
|
||||
.map(|i| (format!("m:r{i}"), json!({"type": "m:t", "pad": big})))
|
||||
.collect();
|
||||
let parts = encode_content(&["m:t".to_owned()], &sources, false);
|
||||
assert!(parts.len() >= 3);
|
||||
let mut n = 0;
|
||||
for (i, p) in parts.iter().enumerate() {
|
||||
assert!(p.len() <= 1_048_576);
|
||||
let part: ContentPart = serde_json::from_slice(p).unwrap();
|
||||
assert_eq!(part.last.is_some(), i + 1 == parts.len());
|
||||
n += part.recipes.len();
|
||||
}
|
||||
assert_eq!(n, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_type_list_keeps_the_last_part_under_the_cap() {
|
||||
let types: Vec<String> = (0..5000)
|
||||
.map(|i| format!("mod{i}:type_with_a_long_name"))
|
||||
.collect();
|
||||
let big = "x".repeat(1_000_000);
|
||||
let sources = vec![(
|
||||
"m:r".to_owned(),
|
||||
json!({"type": "mod0:type_with_a_long_name", "pad": big}),
|
||||
)];
|
||||
let parts = encode_content(&types, &sources, false);
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(parts.iter().all(|p| p.len() <= 1_048_576));
|
||||
let last: ContentPart = serde_json::from_slice(&parts[1]).unwrap();
|
||||
assert!(last.recipes.is_empty());
|
||||
assert_eq!(last.last.map(|t| t.len()), Some(5000));
|
||||
}
|
||||
}
|
||||
@@ -8,17 +8,18 @@ pub mod arrows;
|
||||
pub mod block_contact;
|
||||
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, 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, native_emissive, 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_options, shader_pack_list,
|
||||
signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit,
|
||||
clouds, collisions, command_block, containers, crash, datapack, dynamic_light, effects,
|
||||
environment, 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, native_emissive, 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_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
|
||||
spans, split_layout, split_profiles, 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;
|
||||
@@ -110,6 +111,7 @@ pub mod save;
|
||||
pub mod scoreboard_cmd;
|
||||
pub mod screen_layout;
|
||||
pub mod screen_narrator;
|
||||
pub mod social_screen;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod shadows;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Far terrain from a server (`horizon:lod`): a Horizon client playing on
|
||||
//! another machine's world (a dedicated server, a LAN world) has neither the
|
||||
//! seed nor the generator its far terrain samples, so the server samples the
|
||||
//! tiles for it, up to the distance its owner allows (`horizon-lod-distance`).
|
||||
//! another machine's world has neither the seed nor the generator its far
|
||||
//! terrain samples, so the server samples the tiles for it, up to the distance
|
||||
//! its owner allows (`horizon-lod-distance`). Only a dedicated server can offer
|
||||
//! it for now: an open-to-LAN world has no setting for that distance yet, so
|
||||
//! its `LanConfig::lod_distance` stays 0.
|
||||
//!
|
||||
//! The server offers it with an [`Offer`] payload in the configuration phase,
|
||||
//! to Horizon clients only (a vanilla client never hears of it). The client
|
||||
@@ -23,7 +25,9 @@ use horizon_protocol::{
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::{collections::HashMap, time::Instant};
|
||||
|
||||
mod client;
|
||||
mod server;
|
||||
pub use client::{Inbox, RemoteSource};
|
||||
pub use server::{Delivery, Job, Service, Verdict};
|
||||
|
||||
/// The custom payload channel, both ways.
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! The client side of `horizon:lod`: far terrain a server samples, as the
|
||||
//! `LodSource` of a client of another machine's world. Each sampling thread
|
||||
//! queues one tile request, which the game thread sends with its own packets
|
||||
//! ([`Inbox::requests`]), and waits for its reply, which the network thread
|
||||
//! hands over ([`Inbox::deliver`]), within the server's [`Offer`]. A tile
|
||||
//! refused, lost or unreadable comes back as no columns: `Lod` asks again.
|
||||
use super::{Bucket, Offer, Request, SIDE, decode_reply, reply_id};
|
||||
use crate::lod::{Column, Surface};
|
||||
use crate::streaming::{LodSampler, LodSource};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// How long a client waits for a tile before asking again.
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A connection's far terrain requests: their replies come in on the network
|
||||
/// thread ([`Self::deliver`]) and go to the sampler waiting for each. One per
|
||||
/// connection, so the server's limits hold across the far terrains (one per
|
||||
/// dimension) a connection has in turn.
|
||||
#[derive(Default)]
|
||||
pub struct Inbox {
|
||||
state: Mutex<InboxState>,
|
||||
changed: Condvar,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct InboxState {
|
||||
/// Requests sent and not answered: when each went, and the sampler
|
||||
/// waiting for it (None once given up).
|
||||
waiting: HashMap<u32, (Instant, Option<mpsc::Sender<Vec<u8>>>)>,
|
||||
/// Requests for the game thread to send, by id.
|
||||
outbox: Vec<(u32, Vec<u8>)>,
|
||||
next: u32,
|
||||
bucket: Option<Bucket>,
|
||||
/// Bumped by [`Inbox::cancel`]: samplers of an older one stop.
|
||||
epoch: u64,
|
||||
}
|
||||
impl Inbox {
|
||||
/// A reply from the server: to its sampler, and its slot freed.
|
||||
pub fn deliver(&self, data: Vec<u8>) {
|
||||
let Some(id) = reply_id(&data) else {
|
||||
return;
|
||||
};
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if let Some((_, Some(tx))) = state.waiting.remove(&id) {
|
||||
let _ = tx.send(data);
|
||||
}
|
||||
drop(state);
|
||||
self.changed.notify_all();
|
||||
}
|
||||
/// The requests to send now, from the game thread.
|
||||
pub fn requests(&self) -> Vec<Vec<u8>> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.outbox.drain(..).map(|(_, data)| data).collect()
|
||||
}
|
||||
/// The far terrain ends (another dimension, a disconnect): its samplers
|
||||
/// stop waiting at once. Requests still at the server keep their slot
|
||||
/// until it answers; those not sent yet never go.
|
||||
pub fn cancel(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.epoch += 1;
|
||||
for (id, _) in std::mem::take(&mut state.outbox) {
|
||||
state.waiting.remove(&id);
|
||||
}
|
||||
for (_, tx) in state.waiting.values_mut() {
|
||||
*tx = None;
|
||||
}
|
||||
drop(state);
|
||||
self.changed.notify_all();
|
||||
}
|
||||
/// The connection now talks to another server (a proxy's reconfiguration):
|
||||
/// what the old one was asked can never come back, so its slots and its
|
||||
/// rate go too. Ids keep increasing, so a late reply matches nothing.
|
||||
pub fn reset(&self) {
|
||||
self.cancel();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.waiting.clear();
|
||||
state.bucket = None;
|
||||
drop(state);
|
||||
self.changed.notify_all();
|
||||
}
|
||||
fn epoch(&self) -> u64 {
|
||||
self.state.lock().unwrap().epoch
|
||||
}
|
||||
/// A slot for `request` (its id set), within the offer's limits, once
|
||||
/// there is one, and the request queued; None once the far terrain of
|
||||
/// `epoch` was cancelled.
|
||||
fn send(
|
||||
&self,
|
||||
epoch: u64,
|
||||
offer: Offer,
|
||||
mut request: Request,
|
||||
) -> Option<mpsc::Receiver<Vec<u8>>> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
loop {
|
||||
if state.epoch != epoch {
|
||||
return None;
|
||||
}
|
||||
let now = Instant::now();
|
||||
// A request the server never answered (a reconfiguration lost
|
||||
// it) frees its slot in time.
|
||||
state
|
||||
.waiting
|
||||
.retain(|_, (sent, _)| now.duration_since(*sent) < TIMEOUT);
|
||||
let rate = f64::from(offer.per_second);
|
||||
let free = state.waiting.len() < offer.in_flight as usize;
|
||||
let bucket = state
|
||||
.bucket
|
||||
.get_or_insert_with(|| Bucket::new(rate, rate, now));
|
||||
if free && bucket.take(now) {
|
||||
request.id = state.next;
|
||||
state.next = state.next.wrapping_add(1);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
state.waiting.insert(request.id, (now, Some(tx)));
|
||||
state.outbox.push((request.id, request.encode()));
|
||||
return Some(rx);
|
||||
}
|
||||
state = self
|
||||
.changed
|
||||
.wait_timeout(state, Duration::from_millis(20))
|
||||
.unwrap()
|
||||
.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Far terrain sampled by the server: the `LodSource` of a client of
|
||||
/// another machine's world.
|
||||
pub struct RemoteSource {
|
||||
inbox: Arc<Inbox>,
|
||||
epoch: u64,
|
||||
offer: Offer,
|
||||
/// This client's biome indices by name.
|
||||
biomes: HashMap<String, u16>,
|
||||
/// Block states this client knows.
|
||||
states: u32,
|
||||
/// Samplers made: no more than requests may wait.
|
||||
samplers: AtomicUsize,
|
||||
}
|
||||
impl RemoteSource {
|
||||
pub fn new(
|
||||
inbox: Arc<Inbox>,
|
||||
offer: Offer,
|
||||
biomes: HashMap<String, u16>,
|
||||
states: u32,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
epoch: inbox.epoch(),
|
||||
inbox,
|
||||
offer,
|
||||
biomes,
|
||||
states,
|
||||
samplers: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
/// One tile's columns; none when the server refused it, it was lost,
|
||||
/// or the far terrain ended first: `Lod` asks again later.
|
||||
fn tile(&self, x: i32, z: i32, stride: i32, approximate: bool) -> Vec<Column> {
|
||||
let request = Request {
|
||||
id: 0,
|
||||
x,
|
||||
z,
|
||||
stride,
|
||||
approximate,
|
||||
};
|
||||
let Some(reply) = self.inbox.send(self.epoch, self.offer, request) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(data) = reply.recv_timeout(TIMEOUT) else {
|
||||
return Vec::new();
|
||||
};
|
||||
match decode_reply(&data, &self.biomes, self.states) {
|
||||
Ok(columns) => columns.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: far terrain from the server: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl LodSource for RemoteSource {
|
||||
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
|
||||
self.samplers.fetch_add(1, Ordering::Relaxed);
|
||||
Box::new(RemoteSampler {
|
||||
source: self,
|
||||
approximate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
struct RemoteSampler {
|
||||
source: Arc<RemoteSource>,
|
||||
approximate: bool,
|
||||
}
|
||||
impl LodSampler for RemoteSampler {
|
||||
/// Each sampling thread waits for one request: no more threads than the
|
||||
/// server lets requests wait.
|
||||
fn fork(&self) -> Option<Box<dyn LodSampler>> {
|
||||
let samplers = &self.source.samplers;
|
||||
if samplers.load(Ordering::Relaxed) >= self.source.offer.in_flight as usize {
|
||||
return None;
|
||||
}
|
||||
Some(self.source.clone().sampler())
|
||||
}
|
||||
fn set_approximate(&mut self, approximate: bool) {
|
||||
self.approximate = approximate;
|
||||
}
|
||||
fn sample(&mut self, _x: i32, _z: i32) -> Option<Surface> {
|
||||
None
|
||||
}
|
||||
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<Column> {
|
||||
if side != SIDE {
|
||||
return vec![Column::default(); side * side];
|
||||
}
|
||||
self.source.tile(x, z, stride, self.approximate)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lod_net::refusal;
|
||||
|
||||
fn request(x: i32) -> Request {
|
||||
Request {
|
||||
id: 0,
|
||||
x,
|
||||
z: 0,
|
||||
stride: 1,
|
||||
approximate: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_inbox_frees_its_samplers() {
|
||||
let inbox = Arc::new(Inbox::default());
|
||||
let offer = Offer::new(64);
|
||||
let epoch = inbox.epoch();
|
||||
let sent = inbox.send(epoch, offer, request(0)).unwrap();
|
||||
let id = Request::decode(&inbox.requests()[0]).unwrap().id;
|
||||
// Queued, not sent: cancelling forgets it.
|
||||
let _queued = inbox.send(epoch, offer, request(16)).unwrap();
|
||||
inbox.cancel();
|
||||
assert!(sent.recv_timeout(Duration::from_secs(1)).is_err());
|
||||
assert!(inbox.send(epoch, offer, request(32)).is_none());
|
||||
assert!(inbox.requests().is_empty());
|
||||
// The late reply still frees the slot the sent one held.
|
||||
assert_eq!(inbox.state.lock().unwrap().waiting.len(), 1);
|
||||
inbox.deliver(refusal(id));
|
||||
assert!(inbox.state.lock().unwrap().waiting.is_empty());
|
||||
// A reconfiguration frees the slots still at the old server.
|
||||
let epoch = inbox.epoch();
|
||||
assert!(inbox.send(epoch, offer, request(48)).is_some());
|
||||
assert_eq!(inbox.requests().len(), 1);
|
||||
inbox.reset();
|
||||
let state = inbox.state.lock().unwrap();
|
||||
assert!(state.waiting.is_empty() && state.outbox.is_empty() && state.bucket.is_none());
|
||||
drop(state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_tile_is_asked_again() {
|
||||
let inbox = Arc::new(Inbox::default());
|
||||
let source = RemoteSource::new(inbox.clone(), Offer::new(64), HashMap::new(), 10);
|
||||
let mut sampler = source.clone().sampler();
|
||||
let server = std::thread::spawn(move || {
|
||||
loop {
|
||||
if let Some(data) = inbox.requests().pop() {
|
||||
let id = Request::decode(&data).unwrap().id;
|
||||
inbox.deliver(refusal(id));
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
});
|
||||
// No columns, not 256 empty ones: Lod doesn't keep it.
|
||||
assert!(sampler.grid(0, 0, 1, SIDE).is_empty());
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod breeze;
|
||||
pub mod camel;
|
||||
pub mod caravan;
|
||||
pub mod cem;
|
||||
pub mod cem_anim;
|
||||
pub mod combat;
|
||||
pub mod control;
|
||||
pub mod conversion;
|
||||
@@ -50,6 +51,7 @@ pub mod illager;
|
||||
pub mod illusioner;
|
||||
pub mod item_break;
|
||||
pub mod keyframes;
|
||||
pub mod landing;
|
||||
pub mod leash;
|
||||
pub mod living;
|
||||
pub mod llama;
|
||||
@@ -65,6 +67,7 @@ pub mod piglin;
|
||||
pub mod polar_bear;
|
||||
pub mod rabbit;
|
||||
pub mod raider;
|
||||
pub mod random_textures;
|
||||
pub mod ravager;
|
||||
pub mod render;
|
||||
pub mod riders;
|
||||
@@ -1188,6 +1191,8 @@ pub struct Mob {
|
||||
/// A mannequin's `DATA_PROFILE` (ResolvableProfile): the profile's
|
||||
/// `textures` property resolves to `skin` like a player's.
|
||||
pub profile: Option<horizon_protocol::entity_data::Profile>,
|
||||
/// The packs' random texture variants it wears (`random_textures::choose`).
|
||||
pub random_textures: random_textures::Choice,
|
||||
}
|
||||
/// A mannequin's `description` and `hide_description` (`Mannequin.updateDescription`):
|
||||
/// the default `entity.minecraft.mannequin.label`, none at all, or a component.
|
||||
@@ -1394,6 +1399,16 @@ pub enum MobEvent {
|
||||
ElderCurse {
|
||||
at: [f64; 3],
|
||||
},
|
||||
/// `Entity.setDeltaMovement`: replace a target's velocity.
|
||||
SetVelocity {
|
||||
target: Who,
|
||||
velocity: [f64; 3],
|
||||
},
|
||||
/// `Entity.remove(RemovalReason.KILLED)` of a mob that is no longer alive: it vanishes at once,
|
||||
/// with no death animation (`ShootTongue.eatEntity`).
|
||||
RemoveKilled {
|
||||
target: u32,
|
||||
},
|
||||
/// `Entity.push`: add to a target's velocity.
|
||||
Push {
|
||||
target: Who,
|
||||
@@ -2344,6 +2359,8 @@ pub struct MobWorld<'a> {
|
||||
pub thundering: bool,
|
||||
pub nether: bool,
|
||||
pub block_tags: &'a HashMap<String, std::collections::HashSet<String>>,
|
||||
/// Each block's `fallOn` rule (the player's landing registry).
|
||||
pub landing: &'a horizon_world::block_landing::BlockLandingRegistry,
|
||||
pub biomes: &'a crate::biome::Biomes,
|
||||
/// The `mob_griefing` game rule.
|
||||
pub mob_griefing: bool,
|
||||
@@ -2466,6 +2483,7 @@ impl Mob {
|
||||
slim: false,
|
||||
cape: None,
|
||||
profile: None,
|
||||
random_textures: Default::default(),
|
||||
attrs: Attrs {
|
||||
speed: kind.speed,
|
||||
attack_damage: kind.attack_damage,
|
||||
|
||||
@@ -765,6 +765,20 @@ fn effects_from_blocks(
|
||||
m.damage(1.0, Damage::of(DamageKind::SweetBerryBush));
|
||||
}
|
||||
}
|
||||
// PowderSnowBlock.entityInside: a living entity is stuck
|
||||
// (and forgets its fall) only while the block at its
|
||||
// own feet is powder snow.
|
||||
"minecraft:powder_snow"
|
||||
if w.block_name(
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
) == "minecraft:powder_snow" =>
|
||||
{
|
||||
m.fall_distance = 0.0;
|
||||
m.stuck_multiplier =
|
||||
Vec3::new(f64::from(0.9_f32), 1.5, f64::from(0.9_f32));
|
||||
}
|
||||
"minecraft:honey_block" => honey_slide(m, p, level_random, events),
|
||||
_ => continue,
|
||||
}
|
||||
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user