mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge the Setup PR (main merged) into mouse controls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
a8432f5ada
191 files changed
+10144
-1126
No files matched your search
@@ -91,3 +91,27 @@ licensed under the MIT license:
|
||||
> 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.
|
||||
|
||||
## FusionExamples
|
||||
|
||||
The tile tables in `crates/horizon-optifine/src/fusion/layout.rs` were read off the connecting
|
||||
texture templates (`templates/template <layout>.png`) of
|
||||
[FusionExamples](https://github.com/SuperMartijn642/FusionExamples) (at `8c9c56e`), which is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2024 Martijn van den Brand
|
||||
>
|
||||
> 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.
|
||||
@@ -3,6 +3,7 @@
|
||||
//! from `pack.mcmeta` `language` sections, and a process-wide current
|
||||
//! language used by every screen.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::{
|
||||
Arc, RwLock,
|
||||
@@ -34,6 +35,15 @@ pub struct Language {
|
||||
pub const DEFAULT: &str = "en_us";
|
||||
|
||||
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
|
||||
/// A language that only the calling thread sees, and the keys it was asked for and did not have
|
||||
/// (`scoped`).
|
||||
struct Scope {
|
||||
language: Arc<Language>,
|
||||
missed: Vec<String>,
|
||||
}
|
||||
thread_local! {
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
@@ -254,8 +264,50 @@ pub fn set(language: Language) {
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
|
||||
return Some(language);
|
||||
}
|
||||
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
/// Runs `f` with `language` as the current language of this thread only, and returns what it
|
||||
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
|
||||
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
|
||||
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
|
||||
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
|
||||
/// language's.
|
||||
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
|
||||
/// Puts the outer scope back, also when `f` panics.
|
||||
struct Restore(Option<Scope>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
|
||||
}
|
||||
}
|
||||
let outer = SCOPE.with(|s| {
|
||||
s.borrow_mut().replace(Scope {
|
||||
language: Arc::new(language),
|
||||
missed: Vec::new(),
|
||||
})
|
||||
});
|
||||
let _restore = Restore(outer);
|
||||
let result = f();
|
||||
let missed = SCOPE.with(|s| {
|
||||
s.borrow_mut()
|
||||
.as_mut()
|
||||
.map(|s| std::mem::take(&mut s.missed))
|
||||
});
|
||||
(result, missed.unwrap_or_default())
|
||||
}
|
||||
/// `key` was asked for in a `scoped` language that has no text for it.
|
||||
fn missed(key: &str) {
|
||||
SCOPE.with(|s| {
|
||||
if let Some(scope) = s.borrow_mut().as_mut()
|
||||
&& !scope.missed.iter().any(|m| m == key)
|
||||
{
|
||||
scope.missed.push(key.to_owned());
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ClientLanguage.defaultRightToLeft` of the current language.
|
||||
pub fn bidirectional() -> bool {
|
||||
BIDIRECTIONAL.load(Ordering::Relaxed)
|
||||
@@ -267,13 +319,19 @@ pub fn code() -> String {
|
||||
pub fn get(key: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| key.to_owned())
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
key.to_owned()
|
||||
})
|
||||
}
|
||||
/// The translation, or `fallback` when the key is missing.
|
||||
pub fn get_or(key: &str, fallback: &str) -> String {
|
||||
current()
|
||||
.and_then(|l| l.get(key).map(str::to_owned))
|
||||
.unwrap_or_else(|| fallback.to_owned())
|
||||
.unwrap_or_else(|| {
|
||||
missed(key);
|
||||
fallback.to_owned()
|
||||
})
|
||||
}
|
||||
pub fn has(key: &str) -> bool {
|
||||
current().is_some_and(|l| l.get(key).is_some())
|
||||
@@ -350,6 +408,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let ((german, english_only, fallback, elsewhere), missed) =
|
||||
scoped(Language::load(&[&empty], "de_de"), || {
|
||||
(
|
||||
get("horizon.osk.close"),
|
||||
get("horizon.no_such_key"),
|
||||
get_or("horizon.no_fallback", "English"),
|
||||
// Another thread keeps the process-wide language.
|
||||
std::thread::spawn(code).join().unwrap(),
|
||||
)
|
||||
});
|
||||
assert_eq!(german, "Schließen");
|
||||
assert_eq!(english_only, "horizon.no_such_key");
|
||||
assert_eq!(fallback, "English");
|
||||
assert_eq!(elsewhere, outside);
|
||||
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
|
||||
// The scope ends with its closure.
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_nest_and_end_when_their_closure_panics() {
|
||||
let empty = Mem(HashMap::new());
|
||||
let outside = code();
|
||||
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
|
||||
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
|
||||
(code(), get("horizon.inner_miss"))
|
||||
});
|
||||
assert_eq!(inner_missed, ["horizon.inner_miss"]);
|
||||
assert_eq!(code(), "fr_fr");
|
||||
inner.0
|
||||
});
|
||||
assert_eq!(inner, "pl_pl");
|
||||
assert!(missed.is_empty());
|
||||
let panicked = std::panic::catch_unwind(|| {
|
||||
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
|
||||
panic!("a test failed inside a scope")
|
||||
})
|
||||
});
|
||||
assert!(panicked.is_err());
|
||||
assert_eq!(code(), outside);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -51,6 +51,10 @@ impl InteractionInput {
|
||||
pub fn press(&mut self, button: Button) {
|
||||
self.clicks[button as usize] += 1;
|
||||
}
|
||||
/// Whether a click waits for the next tick.
|
||||
pub fn has_clicks(&self) -> bool {
|
||||
self.clicks.iter().any(|&c| c > 0)
|
||||
}
|
||||
|
||||
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
|
||||
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s
|
||||
//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the
|
||||
//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file
|
||||
//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry
|
||||
//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations
|
||||
//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the
|
||||
//! atlas has it.
|
||||
//!
|
||||
//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and
|
||||
//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest.
|
||||
//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are
|
||||
//! block models here, so their files are not read; copper golem statues have no CEM name.
|
||||
|
||||
use crate::block_entity_render::ModelPart;
|
||||
use horizon_optifine::cem::{
|
||||
files::{atlas_name, jem_files, load_jem},
|
||||
model::{Bone, EntityModel},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's
|
||||
/// names for its vanilla parts.
|
||||
struct Target {
|
||||
key: &'static str,
|
||||
/// The vanilla model it starts from: `key` itself, or the one a block the renderer tells
|
||||
/// apart shares (a trapped chest's is the chest's).
|
||||
base: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
const CHEST: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("base", "bottom"),
|
||||
("lid", "lid"),
|
||||
("knob", "lock"),
|
||||
];
|
||||
const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")];
|
||||
const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")];
|
||||
const FLAG: &[(&str, &str)] = &[("slate", "flag")];
|
||||
const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")];
|
||||
const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")];
|
||||
const PIGLIN: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("head", "head"),
|
||||
("left_ear", "left_ear"),
|
||||
("right_ear", "right_ear"),
|
||||
];
|
||||
const BELL: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("body", "bell_body"),
|
||||
("base", "bell_base"),
|
||||
];
|
||||
const BOOK: &[(&str, &str)] = &[
|
||||
("root", "root"),
|
||||
("cover_right", "right_lid"),
|
||||
("cover_left", "left_lid"),
|
||||
("pages_right", "right_pages"),
|
||||
("pages_left", "left_pages"),
|
||||
("flipping_page_right", "flip_page1"),
|
||||
("flipping_page_left", "flip_page2"),
|
||||
("book_spine", "seam"),
|
||||
];
|
||||
const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")];
|
||||
const POT_SIDES: &[(&str, &str)] = &[
|
||||
("front", "front"),
|
||||
("back", "back"),
|
||||
("left", "left"),
|
||||
("right", "right"),
|
||||
];
|
||||
|
||||
const fn model(
|
||||
key: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
) -> Target {
|
||||
Target {
|
||||
key,
|
||||
base: key,
|
||||
names,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
const fn copy(
|
||||
key: &'static str,
|
||||
base: &'static str,
|
||||
names: &'static [&'static str],
|
||||
parts: &'static [(&'static str, &'static str)],
|
||||
) -> Target {
|
||||
Target {
|
||||
key,
|
||||
base,
|
||||
names,
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
/// In order: a copy comes after its base.
|
||||
const TARGETS: &[Target] = &[
|
||||
model("chest", &["chest"], CHEST),
|
||||
model("double_chest_left", &["chest_left"], CHEST),
|
||||
model("double_chest_right", &["chest_right"], CHEST),
|
||||
copy("trapped_chest", "chest", &["trapped_chest"], CHEST),
|
||||
copy(
|
||||
"trapped_chest_left",
|
||||
"double_chest_left",
|
||||
&["trapped_chest_left"],
|
||||
CHEST,
|
||||
),
|
||||
copy(
|
||||
"trapped_chest_right",
|
||||
"double_chest_right",
|
||||
&["trapped_chest_right"],
|
||||
CHEST,
|
||||
),
|
||||
copy("ender_chest", "chest", &["ender_chest"], CHEST),
|
||||
model("shulker_box", &["shulker_box"], SHULKER),
|
||||
model("standing_banner", &["banner_base", "banner"], BANNER),
|
||||
model("wall_banner", &["banner_base", "banner"], BANNER),
|
||||
model("standing_banner_flag", &["banner_flag", "banner"], FLAG),
|
||||
model("wall_banner_flag", &["banner_flag", "banner"], FLAG),
|
||||
model("skeleton_skull", &["head_skeleton"], HEAD),
|
||||
model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD),
|
||||
model("zombie_head", &["head_zombie"], HEAD),
|
||||
model("creeper_head", &["head_creeper"], HEAD),
|
||||
model("dragon_skull", &["head_dragon"], DRAGON),
|
||||
model("piglin_head", &["head_piglin"], PIGLIN),
|
||||
model("player_head", &["head_player"], HEAD),
|
||||
model("bell", &["bell"], BELL),
|
||||
model("book", &["enchanting_book", "book"], BOOK),
|
||||
copy("lectern_book", "book", &["lectern_book", "book"], BOOK),
|
||||
model("conduit_shell", &["conduit"], &[("base", "shell")]),
|
||||
model("conduit_cage", &["conduit"], &[("cage", "shell")]),
|
||||
model("conduit_wind", &["conduit"], &[("wind", "wind")]),
|
||||
model("conduit_eye", &["conduit"], &[("eye", "eye")]),
|
||||
model("decorated_pot_base", &["decorated_pot"], POT_BASE),
|
||||
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
|
||||
];
|
||||
|
||||
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
|
||||
pub type Reshaped = (
|
||||
HashMap<&'static str, ModelPart>,
|
||||
HashMap<&'static str, String>,
|
||||
);
|
||||
|
||||
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
|
||||
/// a `.jem` there.
|
||||
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
|
||||
let (reshaped, unused) = reshape_all(root, vanilla)?;
|
||||
for (path, part) in unused {
|
||||
log::warn!("Horizon: custom entity model {path}: no part {part}");
|
||||
}
|
||||
Some(reshaped)
|
||||
}
|
||||
|
||||
/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has.
|
||||
fn reshape_all(
|
||||
root: &Path,
|
||||
vanilla: &HashMap<&'static str, ModelPart>,
|
||||
) -> Option<(Reshaped, Vec<(String, String)>)> {
|
||||
let files = jem_files(root);
|
||||
let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n));
|
||||
if !TARGETS.iter().any(named) {
|
||||
return None;
|
||||
}
|
||||
let mut models = vanilla.clone();
|
||||
let mut textures = HashMap::new();
|
||||
let mut reshaped = HashSet::new();
|
||||
// Each file parsed once; which of its entries found a part in some model.
|
||||
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
|
||||
let mut used: BTreeMap<&str, Vec<bool>> = BTreeMap::new();
|
||||
for t in TARGETS {
|
||||
let Some(model) = vanilla.get(t.base) else {
|
||||
continue;
|
||||
};
|
||||
let found = t
|
||||
.names
|
||||
.iter()
|
||||
.flat_map(|name| files.get(*name).into_iter().flatten())
|
||||
.map(String::as_str)
|
||||
.find(|&path| {
|
||||
parsed
|
||||
.entry(path)
|
||||
.or_insert_with(|| load_jem(root, path))
|
||||
.is_some()
|
||||
});
|
||||
let Some(path) = found else {
|
||||
// A copy of a reshaped base that has no file of its own stays vanilla.
|
||||
if t.key != t.base && reshaped.contains(t.base) {
|
||||
models.insert(t.key, model.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
let mut out = model.clone();
|
||||
let hits = apply(&mut out, jem, t.parts);
|
||||
// A file shared by several models (the conduit's, the pot's) paints only the models
|
||||
// it reshapes: vanilla geometry keeps its own texture's layout.
|
||||
let reshapes = hits.iter().any(|hit| *hit);
|
||||
let marks = used
|
||||
.entry(path)
|
||||
.or_insert_with(|| vec![false; jem.parts.len()]);
|
||||
for (mark, hit) in marks.iter_mut().zip(hits) {
|
||||
*mark |= hit;
|
||||
}
|
||||
models.insert(t.key, out);
|
||||
reshaped.insert(t.key);
|
||||
if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) {
|
||||
textures.insert(t.key, texture);
|
||||
}
|
||||
}
|
||||
let mut unused = Vec::new();
|
||||
for (path, marks) in &used {
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) {
|
||||
unused.push(((*path).to_owned(), p.part.clone()));
|
||||
}
|
||||
}
|
||||
Some(((models, textures), unused))
|
||||
}
|
||||
|
||||
/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla
|
||||
/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child
|
||||
/// `cem#<n>`. Which entries found their part.
|
||||
fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec<bool> {
|
||||
let mut count = 0;
|
||||
let mut hits = Vec::with_capacity(jem.parts.len());
|
||||
for p in &jem.parts {
|
||||
let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v);
|
||||
let target = match mapped {
|
||||
Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla,
|
||||
_ => p.part.as_str(),
|
||||
};
|
||||
let Some(node) = find_mut(model, target) else {
|
||||
hits.push(false);
|
||||
continue;
|
||||
};
|
||||
if !p.attach {
|
||||
node.polygons.clear();
|
||||
}
|
||||
let (name, mut bone) = part(&p.bone, &mut count);
|
||||
bone.pose[6..9].fill(p.scale);
|
||||
node.children.push((name, bone));
|
||||
hits.push(true);
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// The model itself for `root`, else its first part named `name`, depth first.
|
||||
fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
|
||||
fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
|
||||
part.children.iter_mut().find_map(|(n, c)| {
|
||||
if n.as_str() == name {
|
||||
Some(c)
|
||||
} else {
|
||||
child(c, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if name == "root" {
|
||||
return Some(model);
|
||||
}
|
||||
child(model, name)
|
||||
}
|
||||
|
||||
/// A baked bone as a model part, named `cem#<n>`: unique in the model, so no vanilla pose
|
||||
/// change by name reaches it.
|
||||
fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) {
|
||||
let name = format!("cem#{count}");
|
||||
*count += 1;
|
||||
let [x, y, z] = bone.pivot;
|
||||
let [rx, ry, rz] = bone.rotation;
|
||||
let out = ModelPart {
|
||||
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
|
||||
polygons: bone.quads.iter().map(|q| q.vertices).collect(),
|
||||
children: bone.children.iter().map(|c| part(c, count)).collect(),
|
||||
};
|
||||
(name, out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
use std::{collections::BTreeSet, fs};
|
||||
|
||||
/// A part with one face and `children`.
|
||||
pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart {
|
||||
ModelPart {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![[[0.0; 5]; 4]],
|
||||
children: children
|
||||
.into_iter()
|
||||
.map(|(n, c)| (n.to_owned(), c))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn chest() -> ModelPart {
|
||||
let mut root = plane(vec![
|
||||
("bottom", plane(vec![])),
|
||||
("lid", plane(vec![])),
|
||||
("lock", plane(vec![])),
|
||||
]);
|
||||
root.polygons.clear();
|
||||
root
|
||||
}
|
||||
pub(crate) const BOX: &str =
|
||||
r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
|
||||
/// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`.
|
||||
pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
for (path, text) in files {
|
||||
let path = root.join("assets/minecraft").join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
/// A `.jem` replacing each of `parts` with one box.
|
||||
fn jem(parts: &[&str]) -> String {
|
||||
let entries: Vec<_> = parts
|
||||
.iter()
|
||||
.map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#))
|
||||
.collect();
|
||||
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
|
||||
}
|
||||
/// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels
|
||||
/// deep, so a part that took another entry's box shows.
|
||||
fn jem_sized(parts: &[&str]) -> String {
|
||||
let entries: Vec<_> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
let depth = i + 1;
|
||||
format!(
|
||||
r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"#
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
|
||||
}
|
||||
/// How deep (z) the custom box under `model`'s part `name` is.
|
||||
fn depth(model: &ModelPart, name: &str) -> f32 {
|
||||
let part = model.child(name).unwrap();
|
||||
let zs = part
|
||||
.children
|
||||
.iter()
|
||||
.flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2]));
|
||||
let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z)));
|
||||
hi - lo
|
||||
}
|
||||
/// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one
|
||||
/// child of a box's six faces.
|
||||
fn replaced(model: &ModelPart, name: &str) -> bool {
|
||||
let part = model.child(name).unwrap();
|
||||
match part.children.as_slice() {
|
||||
[(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_optifine_part_name_lands_on_its_vanilla_part() {
|
||||
// File, OptiFine's part, the model it reshapes, vanilla's part there.
|
||||
let cases = [
|
||||
("chest", "base", "chest", "bottom"),
|
||||
("chest", "lid", "chest", "lid"),
|
||||
("chest", "knob", "chest", "lock"),
|
||||
("chest_left", "base", "double_chest_left", "bottom"),
|
||||
("chest_left", "lid", "double_chest_left", "lid"),
|
||||
("chest_left", "knob", "double_chest_left", "lock"),
|
||||
("chest_right", "base", "double_chest_right", "bottom"),
|
||||
("chest_right", "lid", "double_chest_right", "lid"),
|
||||
("chest_right", "knob", "double_chest_right", "lock"),
|
||||
("trapped_chest", "base", "trapped_chest", "bottom"),
|
||||
("trapped_chest", "knob", "trapped_chest", "lock"),
|
||||
("trapped_chest_left", "base", "trapped_chest_left", "bottom"),
|
||||
("trapped_chest_left", "knob", "trapped_chest_left", "lock"),
|
||||
(
|
||||
"trapped_chest_right",
|
||||
"base",
|
||||
"trapped_chest_right",
|
||||
"bottom",
|
||||
),
|
||||
("trapped_chest_right", "knob", "trapped_chest_right", "lock"),
|
||||
("ender_chest", "base", "ender_chest", "bottom"),
|
||||
("ender_chest", "knob", "ender_chest", "lock"),
|
||||
("shulker_box", "base", "shulker_box", "base"),
|
||||
("shulker_box", "lid", "shulker_box", "lid"),
|
||||
("banner", "stand", "standing_banner", "pole"),
|
||||
("banner", "top", "standing_banner", "bar"),
|
||||
("banner", "top", "wall_banner", "bar"),
|
||||
("banner", "slate", "standing_banner_flag", "flag"),
|
||||
("banner", "slate", "wall_banner_flag", "flag"),
|
||||
("head_skeleton", "head", "skeleton_skull", "head"),
|
||||
(
|
||||
"head_wither_skeleton",
|
||||
"head",
|
||||
"wither_skeleton_skull",
|
||||
"head",
|
||||
),
|
||||
("head_zombie", "head", "zombie_head", "head"),
|
||||
("head_creeper", "head", "creeper_head", "head"),
|
||||
("head_dragon", "head", "dragon_skull", "head"),
|
||||
("head_dragon", "jaw", "dragon_skull", "jaw"),
|
||||
("head_piglin", "head", "piglin_head", "head"),
|
||||
("head_piglin", "left_ear", "piglin_head", "left_ear"),
|
||||
("head_piglin", "right_ear", "piglin_head", "right_ear"),
|
||||
("head_player", "head", "player_head", "head"),
|
||||
("bell", "body", "bell", "bell_body"),
|
||||
("bell", "base", "bell", "bell_base"),
|
||||
("enchanting_book", "cover_right", "book", "right_lid"),
|
||||
("enchanting_book", "cover_left", "book", "left_lid"),
|
||||
("enchanting_book", "pages_right", "book", "right_pages"),
|
||||
("enchanting_book", "pages_left", "book", "left_pages"),
|
||||
(
|
||||
"enchanting_book",
|
||||
"flipping_page_right",
|
||||
"book",
|
||||
"flip_page1",
|
||||
),
|
||||
(
|
||||
"enchanting_book",
|
||||
"flipping_page_left",
|
||||
"book",
|
||||
"flip_page2",
|
||||
),
|
||||
("enchanting_book", "book_spine", "book", "seam"),
|
||||
("lectern_book", "cover_right", "lectern_book", "right_lid"),
|
||||
("lectern_book", "pages_left", "lectern_book", "left_pages"),
|
||||
(
|
||||
"lectern_book",
|
||||
"flipping_page_right",
|
||||
"lectern_book",
|
||||
"flip_page1",
|
||||
),
|
||||
("lectern_book", "book_spine", "lectern_book", "seam"),
|
||||
("conduit", "base", "conduit_shell", "shell"),
|
||||
("conduit", "cage", "conduit_cage", "shell"),
|
||||
("conduit", "wind", "conduit_wind", "wind"),
|
||||
("conduit", "eye", "conduit_eye", "eye"),
|
||||
("decorated_pot", "neck", "decorated_pot_base", "neck"),
|
||||
("decorated_pot", "top", "decorated_pot_base", "top"),
|
||||
("decorated_pot", "bottom", "decorated_pot_base", "bottom"),
|
||||
("decorated_pot", "front", "decorated_pot_sides", "front"),
|
||||
("decorated_pot", "back", "decorated_pot_sides", "back"),
|
||||
("decorated_pot", "left", "decorated_pot_sides", "left"),
|
||||
("decorated_pot", "right", "decorated_pot_sides", "right"),
|
||||
];
|
||||
// One file per name with each of its parts, and a stub of each model holding its parts
|
||||
// (chest models all of theirs, so a misplaced entry shows).
|
||||
let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||||
for (file, part, key, vanilla) in cases {
|
||||
files.entry(file).or_default().insert(part);
|
||||
trees.entry(key).or_default().insert(vanilla);
|
||||
}
|
||||
for key in ["chest", "double_chest_left", "double_chest_right"] {
|
||||
trees
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.extend(["bottom", "lid", "lock"]);
|
||||
}
|
||||
let files_by_name = files.clone();
|
||||
let files: Vec<_> = files
|
||||
.iter()
|
||||
.map(|(file, parts)| {
|
||||
let parts: Vec<_> = parts.iter().copied().collect();
|
||||
(format!("optifine/cem/{file}.jem"), jem_sized(&parts))
|
||||
})
|
||||
.collect();
|
||||
let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect();
|
||||
let root = pack("parts", &files);
|
||||
let vanilla: HashMap<&'static str, ModelPart> = trees
|
||||
.iter()
|
||||
.map(|(key, parts)| {
|
||||
let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect();
|
||||
(*key, plane(parts))
|
||||
})
|
||||
.collect();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, _), unused) = result.unwrap();
|
||||
for (file, part, key, vanilla) in cases {
|
||||
assert!(
|
||||
replaced(&models[key], vanilla),
|
||||
"{file}.jem {part}: {key} {vanilla}"
|
||||
);
|
||||
// The box is this entry's own: swapped names (left and right, body and base) show.
|
||||
let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1;
|
||||
assert_eq!(
|
||||
depth(&models[key], vanilla),
|
||||
own as f32,
|
||||
"{file}.jem {part}: {key} {vanilla}"
|
||||
);
|
||||
}
|
||||
assert_eq!(unused, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() {
|
||||
let root = pack(
|
||||
"chest",
|
||||
&[(
|
||||
"optifine/cem/chest.jem",
|
||||
format!(
|
||||
r#"{{"texture": "textures/entity/chest/fancy.png", "models": [
|
||||
{{"part": "lid", {BOX}}},
|
||||
{{"part": "knob", "attach": true, "scale": 2, {BOX}}},
|
||||
{{"part": "bottom", {BOX}}},
|
||||
{{"part": "tail", {BOX}}}]}}"#
|
||||
),
|
||||
)],
|
||||
);
|
||||
let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, textures), unused) = result.unwrap();
|
||||
let chest = &models["chest"];
|
||||
// Replaced: the lid's own face goes and the custom box (6 faces) joins it.
|
||||
assert!(replaced(chest, "lid"));
|
||||
// `bottom` is no OptiFine name but vanilla's own: it reaches the part as well.
|
||||
assert!(replaced(chest, "bottom"));
|
||||
// Attached: the lock keeps its face beside the scaled box.
|
||||
let lock = chest.child("lock").unwrap();
|
||||
assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1));
|
||||
assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]);
|
||||
// `tail` names no chest part: the one warning.
|
||||
assert_eq!(
|
||||
unused,
|
||||
[(
|
||||
"assets/minecraft/optifine/cem/chest.jem".to_owned(),
|
||||
"tail".to_owned()
|
||||
)]
|
||||
);
|
||||
// The double chest has no file, nor do trapped and ender chests: vanilla's own, and
|
||||
// only the chest takes the texture.
|
||||
for key in ["double_chest_left", "trapped_chest", "ender_chest"] {
|
||||
let lid = models[key].child("lid").unwrap();
|
||||
assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}");
|
||||
}
|
||||
assert_eq!(
|
||||
textures,
|
||||
HashMap::from([("chest", "entity/chest/fancy".to_owned())])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banners_split_one_file_and_books_fall_back() {
|
||||
let root = pack(
|
||||
"banner",
|
||||
&[
|
||||
("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])),
|
||||
("emf/cem/enchanting_book.jem", jem(&["cover_left"])),
|
||||
],
|
||||
);
|
||||
let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]);
|
||||
let wall = plane(vec![("bar", plane(vec![]))]);
|
||||
let flag = plane(vec![("flag", plane(vec![]))]);
|
||||
let book = plane(vec![("left_lid", plane(vec![]))]);
|
||||
let vanilla: HashMap<_, _> = [
|
||||
("standing_banner", banner),
|
||||
("wall_banner", wall),
|
||||
("wall_banner_flag", flag),
|
||||
("book", book),
|
||||
]
|
||||
.into();
|
||||
let result = reshape_all(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let ((models, textures), unused) = result.unwrap();
|
||||
assert!(replaced(&models["standing_banner"], "pole"));
|
||||
assert!(replaced(&models["standing_banner"], "bar"));
|
||||
assert!(replaced(&models["wall_banner"], "bar"));
|
||||
assert!(replaced(&models["wall_banner_flag"], "flag"));
|
||||
assert!(replaced(&models["book"], "left_lid"));
|
||||
// The lectern's book has no file of its own nor `book.jem`: vanilla's.
|
||||
let lectern = models["lectern_book"].child("left_lid").unwrap();
|
||||
assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0));
|
||||
assert!(textures.is_empty());
|
||||
// Each entry found its part in one of the models: no warning.
|
||||
assert_eq!(unused, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn book_jem_reshapes_the_lectern_book_without_its_own_file() {
|
||||
let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]);
|
||||
let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into();
|
||||
let result = reshape(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let (models, _) = result.unwrap();
|
||||
assert!(replaced(&models["book"], "left_lid"));
|
||||
assert!(replaced(&models["lectern_book"], "left_lid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_file_paints_only_the_models_it_reshapes() {
|
||||
// The conduit's file reshapes its eye alone: the shell, cage and wind keep their
|
||||
// vanilla geometry and so their own textures.
|
||||
let file = format!(
|
||||
r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"#
|
||||
);
|
||||
let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]);
|
||||
let vanilla: HashMap<_, _> = [
|
||||
"conduit_shell",
|
||||
"conduit_cage",
|
||||
"conduit_wind",
|
||||
"conduit_eye",
|
||||
]
|
||||
.map(|key| {
|
||||
let part = if key == "conduit_eye" { "eye" } else { "shell" };
|
||||
(key, plane(vec![(part, plane(vec![]))]))
|
||||
})
|
||||
.into();
|
||||
let result = reshape(&root, &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let (models, textures) = result.unwrap();
|
||||
assert!(replaced(&models["conduit_eye"], "eye"));
|
||||
let painted: BTreeSet<_> = textures.keys().copied().collect();
|
||||
assert_eq!(painted, BTreeSet::from(["conduit_eye"]));
|
||||
assert_eq!(textures["conduit_eye"], "entity/conduit/custom");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,12 @@ use horizon_world::{
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, f32::consts::PI, path::Path, sync::Mutex};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
f32::consts::PI,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex, PoisonError, RwLock},
|
||||
};
|
||||
|
||||
/// One baked `ModelPart`: initial pose, textured polygons (pixel units,
|
||||
/// normalized UVs) and named children.
|
||||
@@ -802,16 +807,102 @@ pub struct Scene<'a> {
|
||||
/// Empty by default (a dedicated server): no state has a block entity model.
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityRenderer {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
vanilla: Arc<BlockEntityShapes>,
|
||||
/// What draws: `vanilla`, or the packs' custom entity models over it.
|
||||
shapes: RwLock<Arc<BlockEntityShapes>>,
|
||||
kinds: Vec<Kind>,
|
||||
// Closed chest geometry: dimension lighting, chest part, horizontal facing.
|
||||
closed_chests: [[[Vec<Vertex>; 4]; 3]; 2],
|
||||
/// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`).
|
||||
pot_patterns: HashMap<String, String>,
|
||||
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
|
||||
visibility: Option<Mutex<FaceVisibility>>,
|
||||
sign_texts: Mutex<SignTexts>,
|
||||
}
|
||||
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
|
||||
/// models over them ([`crate::block_entity_cem`]).
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityShapes {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
/// Closed chest geometry by chest model: dimension lighting, horizontal facing.
|
||||
closed_chests: HashMap<&'static str, [[Vec<Vertex>; 4]; 2]>,
|
||||
/// A `.jem`'s texture (atlas name) by model, drawn instead of vanilla's.
|
||||
textures: HashMap<&'static str, String>,
|
||||
}
|
||||
/// The chest models, each baked with its lid closed.
|
||||
const CHEST_MODELS: [&str; 7] = [
|
||||
"chest",
|
||||
"double_chest_left",
|
||||
"double_chest_right",
|
||||
"trapped_chest",
|
||||
"trapped_chest_left",
|
||||
"trapped_chest_right",
|
||||
"ender_chest",
|
||||
];
|
||||
impl BlockEntityShapes {
|
||||
fn new(
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
textures: HashMap<&'static str, String>,
|
||||
) -> Self {
|
||||
let closed_chests = CHEST_MODELS
|
||||
.iter()
|
||||
.filter_map(|key| Some((*key, closed_chest(models.get(key)?))))
|
||||
.collect();
|
||||
Self {
|
||||
models,
|
||||
closed_chests,
|
||||
textures,
|
||||
}
|
||||
}
|
||||
/// The texture model `key` draws with: its `.jem`'s when the atlas has it, else `vanilla`.
|
||||
fn texture<'a>(
|
||||
&'a self,
|
||||
key: &str,
|
||||
vanilla: &'a str,
|
||||
sprites: &HashMap<String, Rect>,
|
||||
) -> &'a str {
|
||||
match self.textures.get(key) {
|
||||
Some(texture) if sprites.contains_key(texture) => texture,
|
||||
_ => vanilla,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A chest model with its lid closed, for each dimension lighting and horizontal facing, its UVs
|
||||
/// over the whole texture.
|
||||
fn closed_chest(model: &ModelPart) -> [[Vec<Vertex>; 4]; 2] {
|
||||
[false, true].map(|nether| {
|
||||
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
|
||||
let mut vertices = Vec::new();
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
Vec3::new(0.5, 0.0, 0.5),
|
||||
),
|
||||
&|name, p| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
|
||||
}
|
||||
},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(nether),
|
||||
glow: None,
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
vertices
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
|
||||
/// again each frame, glyph by glyph; its vertices only change with what is
|
||||
/// in the key, so they are kept relative to the block and moved to the camera.
|
||||
@@ -894,6 +985,8 @@ impl BlockEntityRenderer {
|
||||
.collect();
|
||||
let mut renderer = Self::new(models, registry);
|
||||
renderer.visibility = Some(Mutex::new(visibility));
|
||||
// The packs' custom entity models (`.jem` files), before anything draws.
|
||||
renderer.set_shapes(renderer.shapes_for(root));
|
||||
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
|
||||
let items = match horizon_world::item_defaults::read(root) {
|
||||
Ok(items) => items,
|
||||
@@ -920,44 +1013,7 @@ impl BlockEntityRenderer {
|
||||
Ok(renderer)
|
||||
}
|
||||
fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self {
|
||||
let closed_chests = [false, true].map(|nether| {
|
||||
["chest", "double_chest_left", "double_chest_right"].map(|name| {
|
||||
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
|
||||
let mut vertices = Vec::new();
|
||||
if let Some(model) = models.get(name) {
|
||||
draw_part(
|
||||
model,
|
||||
"root",
|
||||
around(
|
||||
Mat4::from_rotation_y((-y_rot).to_radians()),
|
||||
Vec3::new(0.5, 0.0, 0.5),
|
||||
),
|
||||
&|name, p| {
|
||||
if name == "lid" || name == "lock" {
|
||||
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
|
||||
}
|
||||
},
|
||||
Paint {
|
||||
sprite: Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
tint: 0xFFFFFF,
|
||||
light: 0,
|
||||
lights: diffuse_lights(nether),
|
||||
glow: None,
|
||||
},
|
||||
&mut vertices,
|
||||
);
|
||||
}
|
||||
vertices
|
||||
})
|
||||
})
|
||||
});
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(models, HashMap::new()));
|
||||
let kinds = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
registry.state(BlockStateId(id)).map_or(Kind::None, |s| {
|
||||
@@ -970,14 +1026,31 @@ impl BlockEntityRenderer {
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
models,
|
||||
shapes: RwLock::new(vanilla.clone()),
|
||||
vanilla,
|
||||
kinds,
|
||||
closed_chests,
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
}
|
||||
}
|
||||
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
|
||||
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
|
||||
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
|
||||
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
|
||||
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
|
||||
None => self.vanilla.clone(),
|
||||
}
|
||||
}
|
||||
pub fn set_shapes(&self, shapes: Arc<BlockEntityShapes>) {
|
||||
*self.shapes.write().unwrap_or_else(PoisonError::into_inner) = shapes;
|
||||
}
|
||||
fn shapes(&self) -> Arc<BlockEntityShapes> {
|
||||
self.shapes
|
||||
.read()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
fn kind(&self, state: u32) -> &Kind {
|
||||
self.kinds.get(state as usize).unwrap_or(&Kind::None)
|
||||
}
|
||||
@@ -1271,6 +1344,7 @@ impl BlockEntityRenderer {
|
||||
world_chests: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let shapes = self.shapes();
|
||||
let sprite = |name: &str| scene.sprites.get(name).copied();
|
||||
let paint = |name: &str, tint: u32, packed: u8| {
|
||||
let rect = if crumbling {
|
||||
@@ -1363,12 +1437,34 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
*texture
|
||||
};
|
||||
let model = match part {
|
||||
ChestPart::Single => "chest",
|
||||
ChestPart::Left => "double_chest_left",
|
||||
ChestPart::Right => "double_chest_right",
|
||||
// Trapped and ender chests have models of their own when a `.jem` reshapes
|
||||
// them; copper chests have no CEM name and draw vanilla's chest.
|
||||
let shapes: &BlockEntityShapes =
|
||||
if matches!(block.as_str(), "chest" | "trapped_chest" | "ender_chest") {
|
||||
&shapes
|
||||
} else {
|
||||
&self.vanilla
|
||||
};
|
||||
let (own, chest) = match (block.as_str(), part) {
|
||||
("trapped_chest", ChestPart::Single) => ("trapped_chest", "chest"),
|
||||
("trapped_chest", ChestPart::Left) => {
|
||||
("trapped_chest_left", "double_chest_left")
|
||||
}
|
||||
("trapped_chest", ChestPart::Right) => {
|
||||
("trapped_chest_right", "double_chest_right")
|
||||
}
|
||||
("ender_chest", _) => ("ender_chest", "chest"),
|
||||
(_, ChestPart::Single) => ("chest", "chest"),
|
||||
(_, ChestPart::Left) => ("double_chest_left", "double_chest_left"),
|
||||
(_, ChestPart::Right) => ("double_chest_right", "double_chest_right"),
|
||||
};
|
||||
let Some(model) = self.models.get(model) else {
|
||||
let key = if shapes.models.contains_key(own) {
|
||||
own
|
||||
} else {
|
||||
chest
|
||||
};
|
||||
let texture = shapes.texture(key, texture, scene.sprites);
|
||||
let Some(model) = shapes.models.get(key) else {
|
||||
return;
|
||||
};
|
||||
let (open, other) = self.chest_openness(
|
||||
@@ -1384,9 +1480,11 @@ impl BlockEntityRenderer {
|
||||
packed = (packed >> 4).max(l >> 4) << 4 | (packed & 15).max(l & 15);
|
||||
}
|
||||
if world_chests && open == 0.0 {
|
||||
if let Some(paint) = paint(texture, 0xFFFFFF, packed) {
|
||||
let vertices = &self.closed_chests[usize::from(scene.nether)]
|
||||
[*part as usize][*y_rot as usize / 90];
|
||||
if let (Some(paint), Some(closed)) = (
|
||||
paint(texture, 0xFFFFFF, packed),
|
||||
shapes.closed_chests.get(key),
|
||||
) {
|
||||
let vertices = &closed[usize::from(scene.nether)][*y_rot as usize / 90];
|
||||
let origin = base.w_axis.truncate();
|
||||
let passes = std::iter::once((paint.sprite, paint.light))
|
||||
.chain(paint.glow.map(|g| (g, FULL_BRIGHT)));
|
||||
@@ -1421,8 +1519,12 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::Shulker { texture, facing } => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("shulker_box"),
|
||||
paint(texture, 0xFFFFFF, packed),
|
||||
shapes.models.get("shulker_box"),
|
||||
paint(
|
||||
shapes.texture("shulker_box", texture, scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1446,12 +1548,13 @@ impl BlockEntityRenderer {
|
||||
draw_part(model, "root", m, &lid, paint, out);
|
||||
}
|
||||
Kind::Banner { color, wall, angle } => {
|
||||
let (main, flag) = if *wall {
|
||||
let (main_key, flag_key) = if *wall {
|
||||
("wall_banner", "wall_banner_flag")
|
||||
} else {
|
||||
("standing_banner", "standing_banner_flag")
|
||||
};
|
||||
let (Some(main), Some(flag)) = (self.models.get(main), self.models.get(flag))
|
||||
let (Some(main), Some(flag)) =
|
||||
(shapes.models.get(main_key), shapes.models.get(flag_key))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1472,8 +1575,13 @@ impl BlockEntityRenderer {
|
||||
p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).mcos()) * PI;
|
||||
}
|
||||
};
|
||||
if let Some(paint) = paint("entity/banner/banner_base", 0xFFFFFF, packed) {
|
||||
let banner_base = "entity/banner/banner_base";
|
||||
let main_texture = shapes.texture(main_key, banner_base, scene.sprites);
|
||||
if let Some(paint) = paint(main_texture, 0xFFFFFF, packed) {
|
||||
draw_part(main, "root", m, &none, paint, out);
|
||||
}
|
||||
let flag_texture = shapes.texture(flag_key, banner_base, scene.sprites);
|
||||
if let Some(paint) = paint(flag_texture, 0xFFFFFF, packed) {
|
||||
draw_part(flag, "root", m, &wave, paint, out);
|
||||
}
|
||||
// Pattern passes must not multiply the crumbling decal.
|
||||
@@ -1515,9 +1623,12 @@ impl BlockEntityRenderer {
|
||||
None
|
||||
};
|
||||
let has_profile = skin.is_some();
|
||||
let texture = skin.as_deref().unwrap_or(texture);
|
||||
// A `.jem` texture stands in for the default skin, not a profile's.
|
||||
let texture = skin
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| shapes.texture(model, texture, scene.sprites));
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
(shapes.models.get(model), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1559,8 +1670,12 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::Bell => {
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("bell"),
|
||||
paint("entity/bell/bell_body", 0xFFFFFF, packed),
|
||||
shapes.models.get("bell"),
|
||||
paint(
|
||||
shapes.texture("bell", "entity/bell/bell_body", scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1588,8 +1703,8 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
Kind::DecoratedPot { y_rot } => {
|
||||
let (Some(base_model), Some(sides)) = (
|
||||
self.models.get("decorated_pot_base"),
|
||||
self.models.get("decorated_pot_sides"),
|
||||
shapes.models.get("decorated_pot_base"),
|
||||
shapes.models.get("decorated_pot_sides"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1617,17 +1732,20 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
let first = out.len();
|
||||
if let Some(paint) =
|
||||
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
|
||||
{
|
||||
let pot_base = "entity/decorated_pot/decorated_pot_base";
|
||||
let pot_base = shapes.texture("decorated_pot_base", pot_base, scene.sprites);
|
||||
if let Some(paint) = paint(pot_base, 0xFFFFFF, packed) {
|
||||
draw_part(base_model, "root", m, &none, paint, out);
|
||||
}
|
||||
// PotDecorations (back, left, right, front); items without a pattern use the blank side.
|
||||
// PotDecorations (back, left, right, front); items without a pattern use the blank
|
||||
// side (a `.jem`'s texture, if any).
|
||||
let blank = "entity/decorated_pot/decorated_pot_side";
|
||||
let blank = shapes.texture("decorated_pot_sides", blank, scene.sprites);
|
||||
for (side, item) in ["back", "left", "right", "front"].iter().zip(&a.sherds) {
|
||||
let texture = item
|
||||
.as_ref()
|
||||
.and_then(|i| self.pot_patterns.get(i))
|
||||
.map_or("entity/decorated_pot/decorated_pot_side", String::as_str);
|
||||
.map_or(blank, String::as_str);
|
||||
if let (Some(part), Some(paint)) =
|
||||
(sides.child(side), paint(texture, 0xFFFFFF, packed))
|
||||
{
|
||||
@@ -1677,11 +1795,14 @@ impl BlockEntityRenderer {
|
||||
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
|
||||
// ConduitRenderer.extractRenderState.
|
||||
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
|
||||
let tex = |name: &str| paint(name, 0xFFFFFF, packed);
|
||||
let tex = |key: &str, name: &str| {
|
||||
paint(shapes.texture(key, name, scene.sprites), 0xFFFFFF, packed)
|
||||
};
|
||||
if !active {
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_shell"), tex("entity/conduit/base"))
|
||||
else {
|
||||
let (Some(model), Some(paint)) = (
|
||||
shapes.models.get("conduit_shell"),
|
||||
tex("conduit_shell", "entity/conduit/base"),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let m = base
|
||||
@@ -1696,9 +1817,10 @@ impl BlockEntityRenderer {
|
||||
let hh = (anim_time * 0.1).msin() / 2.0 + 0.5;
|
||||
let hh = hh * hh + hh;
|
||||
let lift = Vec3::new(0.5, 0.3 + hh * 0.2, 0.5);
|
||||
if let (Some(model), Some(paint)) =
|
||||
(self.models.get("conduit_cage"), tex("entity/conduit/cage"))
|
||||
{
|
||||
if let (Some(model), Some(paint)) = (
|
||||
shapes.models.get("conduit_cage"),
|
||||
tex("conduit_cage", "entity/conduit/cage"),
|
||||
) {
|
||||
let axis = Vec3::new(0.5, 1.0, 0.5).normalize();
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
@@ -1714,7 +1836,9 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
"entity/conduit/wind"
|
||||
};
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_wind"), tex(wind)) {
|
||||
if let (Some(model), Some(paint)) =
|
||||
(shapes.models.get("conduit_wind"), tex("conduit_wind", wind))
|
||||
{
|
||||
let turn = match phase {
|
||||
1 => Mat4::from_rotation_x(PI / 2.0),
|
||||
2 => Mat4::from_rotation_z(PI / 2.0),
|
||||
@@ -1732,7 +1856,9 @@ impl BlockEntityRenderer {
|
||||
} else {
|
||||
"entity/conduit/closed_eye"
|
||||
};
|
||||
if let (Some(model), Some(paint)) = (self.models.get("conduit_eye"), tex(eye)) {
|
||||
if let (Some(model), Some(paint)) =
|
||||
(shapes.models.get("conduit_eye"), tex("conduit_eye", eye))
|
||||
{
|
||||
let m = base
|
||||
* Mat4::from_translation(lift)
|
||||
* Mat4::from_scale(Vec3::splat(0.5))
|
||||
@@ -1743,9 +1869,14 @@ impl BlockEntityRenderer {
|
||||
}
|
||||
}
|
||||
Kind::EnchantingTable => {
|
||||
let book = "entity/enchantment/enchanting_table_book";
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("book"),
|
||||
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
|
||||
shapes.models.get("book"),
|
||||
paint(
|
||||
shapes.texture("book", book, scene.sprites),
|
||||
0xFFFFFF,
|
||||
packed,
|
||||
),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1764,9 +1895,16 @@ impl BlockEntityRenderer {
|
||||
draw_part(model, "root", m, &book_pose(openness, ff1, ff2), paint, out);
|
||||
}
|
||||
Kind::Lectern { y_rot } => {
|
||||
// Its own `.jem`'s book, else the enchanting table's.
|
||||
let key = if shapes.models.contains_key("lectern_book") {
|
||||
"lectern_book"
|
||||
} else {
|
||||
"book"
|
||||
};
|
||||
let book = "entity/enchantment/enchanting_table_book";
|
||||
let (Some(model), Some(paint)) = (
|
||||
self.models.get("book"),
|
||||
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
|
||||
shapes.models.get(key),
|
||||
paint(shapes.texture(key, book, scene.sprites), 0xFFFFFF, packed),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
@@ -1797,7 +1935,7 @@ impl BlockEntityRenderer {
|
||||
_ => "copper_golem",
|
||||
};
|
||||
let (Some(model), Some(paint)) =
|
||||
(self.models.get(key), paint(texture, 0xFFFFFF, packed))
|
||||
(shapes.models.get(key), paint(texture, 0xFFFFFF, packed))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -2449,7 +2587,8 @@ mod tests {
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
models: HashMap::new(),
|
||||
vanilla: Default::default(),
|
||||
shapes: Default::default(),
|
||||
kinds: vec![
|
||||
Kind::Sign {
|
||||
hanging: false,
|
||||
@@ -2462,7 +2601,6 @@ mod tests {
|
||||
angle: 90.0,
|
||||
},
|
||||
],
|
||||
closed_chests: Default::default(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
@@ -2646,4 +2784,131 @@ mod tests {
|
||||
"entitySolid culls the pot's back faces"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
|
||||
use crate::block_entity_cem::tests::{BOX, chest, pack};
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(
|
||||
HashMap::from([("chest", chest())]),
|
||||
HashMap::new(),
|
||||
));
|
||||
let renderer = BlockEntityRenderer {
|
||||
shapes: RwLock::new(vanilla.clone()),
|
||||
vanilla,
|
||||
kinds: Vec::new(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
// No block entity's `.jem` (a mob's only): vanilla's shapes, not a copy.
|
||||
let empty = pack("render-none", &[("optifine/cem/zombie.jem", "{}".into())]);
|
||||
assert!(Arc::ptr_eq(&renderer.shapes_for(&empty), &renderer.vanilla));
|
||||
let root = pack(
|
||||
"render-chest",
|
||||
&[(
|
||||
"optifine/cem/chest.jem",
|
||||
format!(
|
||||
r#"{{"texture": "textures/entity/chest/fancy.png",
|
||||
"models": [{{"part": "lid", {BOX}}}]}}"#
|
||||
),
|
||||
)],
|
||||
);
|
||||
renderer.set_shapes(renderer.shapes_for(&root));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&empty);
|
||||
let rect = |u0: f32| Rect {
|
||||
u0,
|
||||
v0: 0.0,
|
||||
u1: u0 + 0.2,
|
||||
v1: 0.2,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
let sprites: HashMap<String, Rect> = [
|
||||
("entity/chest/normal", rect(0.0)),
|
||||
("entity/chest/ender", rect(0.2)),
|
||||
("entity/chest/fancy", rect(0.4)),
|
||||
("entity/chest/copper", rect(0.6)),
|
||||
]
|
||||
.map(|(name, r)| (name.to_owned(), r))
|
||||
.into();
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyphs: 0,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
|
||||
let kind = Kind::Chest {
|
||||
block: block.into(),
|
||||
texture,
|
||||
part: ChestPart::Single,
|
||||
y_rot: 0.0,
|
||||
double_with: None,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
renderer.render_one(
|
||||
&scene,
|
||||
[0; 3],
|
||||
&kind,
|
||||
Mat4::IDENTITY,
|
||||
0xF0,
|
||||
diffuse_lights(false),
|
||||
false,
|
||||
true,
|
||||
&mut out,
|
||||
);
|
||||
out.iter().map(|v| (v.pos, v.uv)).collect::<Vec<_>>()
|
||||
};
|
||||
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
|
||||
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
|
||||
// texture turns festive in late December.)
|
||||
for (block, texture, u0, faces) in [
|
||||
("chest", "entity/chest/normal", 0.4, 8),
|
||||
("ender_chest", "entity/chest/ender", 0.2, 3),
|
||||
("copper_chest", "entity/chest/copper", 0.6, 3),
|
||||
] {
|
||||
let out = draw(&renderer, block, texture);
|
||||
assert_eq!(out.len(), faces * 4, "{block}");
|
||||
assert!(
|
||||
out.iter().all(|(_, uv)| (u0..=u0 + 0.2).contains(&uv[0])),
|
||||
"{block}"
|
||||
);
|
||||
}
|
||||
// A copper chest draws exactly what it draws without any pack.
|
||||
let plain = BlockEntityRenderer {
|
||||
shapes: RwLock::new(renderer.vanilla.clone()),
|
||||
vanilla: renderer.vanilla.clone(),
|
||||
kinds: Vec::new(),
|
||||
pot_patterns: HashMap::new(),
|
||||
visibility: None,
|
||||
sign_texts: Mutex::default(),
|
||||
};
|
||||
assert_eq!(
|
||||
draw(&renderer, "copper_chest", "entity/chest/copper"),
|
||||
draw(&plain, "copper_chest", "entity/chest/copper")
|
||||
);
|
||||
// The atlas has no such texture: vanilla's.
|
||||
let shapes = renderer.shapes();
|
||||
assert_eq!(
|
||||
shapes.texture("chest", "entity/chest/normal", &HashMap::new()),
|
||||
"entity/chest/normal"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -387,7 +387,8 @@ impl ConnectedTextures {
|
||||
|
||||
/// 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.
|
||||
/// by rules that test one; `texture` the quad's own texture directions, asked only by rules
|
||||
/// with `orient=texture`.
|
||||
pub fn pick<'b>(
|
||||
&self,
|
||||
state: u32,
|
||||
@@ -396,6 +397,7 @@ impl ConnectedTextures {
|
||||
pos: [i32; 3],
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
texture: impl Fn() -> Option<Axes>,
|
||||
mut overlay: impl FnMut(Overlay),
|
||||
) -> Pick {
|
||||
let (mut by_state, mut by_layer) = (
|
||||
@@ -427,9 +429,58 @@ impl ConnectedTextures {
|
||||
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, &mut overlay)
|
||||
&& let Some(pick) = self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
face,
|
||||
pos,
|
||||
&neighbour,
|
||||
&biome,
|
||||
&texture,
|
||||
&mut overlay,
|
||||
)
|
||||
{
|
||||
// "Tiles output by CTM rules can also be matched by another rule": once, by the
|
||||
// tile rules naming the picked tile (and this block, if they name blocks).
|
||||
// A tile on the face's own sprite (`tiles=stone`) is no new tile.
|
||||
let Pick::Tile(TileLayer::Layer { layer: picked, .. }) = pick else {
|
||||
return pick;
|
||||
};
|
||||
if picked == layer {
|
||||
return pick;
|
||||
}
|
||||
let own = self.list(&self.by_state, state as usize);
|
||||
// Rules tried on the face's sprite already (those before this one) are not tried
|
||||
// again: an overlay would draw twice.
|
||||
let first = self.list(&self.by_layer, layer as usize);
|
||||
let again = self
|
||||
.list(&self.by_layer, picked as usize)
|
||||
.iter()
|
||||
.map(|&j| (j, &self.rules[usize::from(j)]))
|
||||
.filter(|&(j, r)| {
|
||||
j != i
|
||||
&& !(j < i && first.contains(&j))
|
||||
&& (r.rule.match_blocks.is_empty() || own.contains(&j))
|
||||
});
|
||||
for (_, r) in again {
|
||||
// Joins by tile still compare the face's own sprite with its neighbours'.
|
||||
match self.apply(
|
||||
r,
|
||||
state,
|
||||
layer,
|
||||
face,
|
||||
pos,
|
||||
&neighbour,
|
||||
&biome,
|
||||
&texture,
|
||||
&mut overlay,
|
||||
) {
|
||||
Some(Pick::Keep) => return pick,
|
||||
Some(chained) => return chained,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
@@ -447,6 +498,7 @@ impl ConnectedTextures {
|
||||
pos: [i32; 3],
|
||||
neighbour: &impl Fn([i32; 3]) -> u32,
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
texture: &impl Fn() -> Option<Axes>,
|
||||
overlay: &mut impl FnMut(Overlay),
|
||||
) -> Option<Pick> {
|
||||
let rule = &r.rule;
|
||||
@@ -487,7 +539,12 @@ impl ConnectedTextures {
|
||||
};
|
||||
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.
|
||||
// The quad's own texture directions, whatever face `symmetry` stands it for.
|
||||
// Not for `overlay`, which Continuity's `orient` leaves out: its 17 tiles always face
|
||||
// the standard way.
|
||||
(Orient::Texture, _) if rule.method != Method::Overlay => {
|
||||
texture().unwrap_or(Axes::cube(face))
|
||||
}
|
||||
_ => Axes::cube(face),
|
||||
};
|
||||
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
|
||||
@@ -649,6 +706,10 @@ mod tests {
|
||||
|name| match name {
|
||||
"minecraft:block/stone" => Some(7),
|
||||
"minecraft:block/glass" => Some(3),
|
||||
// The tile `b0` of a rule second in order (`a_picked_tile_can_be_picked_again`).
|
||||
"assets/minecraft/optifine/ctm/b0.png" => Some(2000),
|
||||
// And `a0` of the first rule.
|
||||
"assets/minecraft/optifine/ctm/a0.png" => Some(1000),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
@@ -671,7 +732,7 @@ mod tests {
|
||||
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, |_| {})
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None, || None, |_| {})
|
||||
}
|
||||
|
||||
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
|
||||
@@ -794,8 +855,26 @@ mod tests {
|
||||
)]);
|
||||
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, |_| {});
|
||||
let lower = ctm.pick(
|
||||
5,
|
||||
3,
|
||||
Face::North,
|
||||
[x, 64, 0],
|
||||
|_| 0,
|
||||
|| None,
|
||||
|| None,
|
||||
|_| {},
|
||||
);
|
||||
let upper = ctm.pick(
|
||||
6,
|
||||
3,
|
||||
Face::North,
|
||||
[x, 65, 0],
|
||||
|_| 0,
|
||||
|| None,
|
||||
|| None,
|
||||
|_| {},
|
||||
);
|
||||
assert_eq!(lower, upper, "{x}");
|
||||
tiles.insert(format!("{lower:?}"));
|
||||
}
|
||||
@@ -809,7 +888,16 @@ mod tests {
|
||||
"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), |_| {})
|
||||
ctm.pick(
|
||||
1,
|
||||
3,
|
||||
Face::Up,
|
||||
[0, y, 0],
|
||||
|_| 0,
|
||||
|| Some(biome),
|
||||
|| None,
|
||||
|_| {},
|
||||
)
|
||||
};
|
||||
assert_eq!(at(10, "minecraft:desert"), tile(1000));
|
||||
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
|
||||
@@ -844,7 +932,7 @@ mod tests {
|
||||
"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, |_| {});
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None, || 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));
|
||||
@@ -925,6 +1013,7 @@ mod tests {
|
||||
[0, 64, 0],
|
||||
at,
|
||||
|| None,
|
||||
|| None,
|
||||
|o| out.push(o.layer),
|
||||
);
|
||||
(out, pick)
|
||||
@@ -962,6 +1051,7 @@ mod tests {
|
||||
[0, 64, 0],
|
||||
|o| if o == [0, 0, -1] { 1 } else { 0 },
|
||||
|| None,
|
||||
|| None,
|
||||
|o| {
|
||||
tint = Some((o.tint, o.translucent));
|
||||
},
|
||||
@@ -999,6 +1089,7 @@ mod tests {
|
||||
[0, 64, 0],
|
||||
door_north,
|
||||
|| None,
|
||||
|| None,
|
||||
|o| got.push(o),
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1039,6 +1130,99 @@ mod tests {
|
||||
assert_eq!((layers, pick), (vec![1001, 3000], Pick::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picked_tile_can_be_picked_again() {
|
||||
// b (rule 1) gives glass its tile b0, layer 2000; t (rule 0, a tile rule, tried first)
|
||||
// names that tile, so it draws in b0's place.
|
||||
let chain = |t: &str| {
|
||||
world(&[
|
||||
("b.properties", "matchBlocks=glass\nmethod=fixed\ntiles=b0"),
|
||||
("t.properties", t),
|
||||
])
|
||||
};
|
||||
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=0");
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(1000));
|
||||
// A chained rule keeping the tile (<default>) leaves b's.
|
||||
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmethod=fixed\ntiles=<default>");
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// One that names another block doesn't chain on glass.
|
||||
let ctm = chain("matchTiles=optifine/ctm/b0.png\nmatchBlocks=stone\nmethod=fixed\ntiles=0");
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
// An overlay tried on the face's own sprite and on the picked tile draws once.
|
||||
let ctm = chain("matchTiles=glass optifine/ctm/b0.png\nmethod=overlay_fixed\ntiles=0");
|
||||
let (overlays, pick) = overlays_in(&ctm, 1, Face::Up, &[]);
|
||||
assert_eq!((overlays, pick), (vec![1000], tile(2000)));
|
||||
// A rule after the one that picked, naming both the face's sprite and the tile, wasn't
|
||||
// tried: it chains.
|
||||
let ctm = world(&[
|
||||
("a.properties", "matchTiles=glass\nmethod=fixed\ntiles=a0"),
|
||||
(
|
||||
"c.properties",
|
||||
"matchTiles=glass optifine/ctm/a0.png\nmethod=fixed\ntiles=0",
|
||||
),
|
||||
]);
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orient_texture_follows_the_quads_uvs() {
|
||||
let rule = |orient: &str| {
|
||||
world(&[(
|
||||
"glass.properties",
|
||||
&*format!("matchBlocks=glass\nmethod=horizontal\ntiles=0-3\norient={orient}"),
|
||||
)])
|
||||
};
|
||||
// A mirrored north face (a vanilla pane's): its texture's right is east.
|
||||
let mirrored = || {
|
||||
Some(Axes {
|
||||
right: [1, 0, 0],
|
||||
up: [0, 1, 0],
|
||||
})
|
||||
};
|
||||
let east = |o: [i32; 3]| if o == [1, 0, 0] { 1 } else { 0 };
|
||||
let at = |ctm: &ConnectedTextures| {
|
||||
ctm.pick(
|
||||
1,
|
||||
3,
|
||||
Face::North,
|
||||
[0, 64, 0],
|
||||
east,
|
||||
|| None,
|
||||
mirrored,
|
||||
|_| {},
|
||||
)
|
||||
};
|
||||
// Glass to the east is to the texture's right: the row's left end.
|
||||
assert_eq!(at(&rule("texture")), tile(1000));
|
||||
// By the cube's directions it is to the left: the row's right end.
|
||||
assert_eq!(at(&rule("none")), tile(1002));
|
||||
// A plain overlay keeps the standard directions: glass north of a top face is above it
|
||||
// (tile 15) whatever the quad's texture says.
|
||||
let overlay = world(&[(
|
||||
"a.properties",
|
||||
"matchBlocks=stone\nmethod=overlay\nconnectBlocks=glass\ntiles=0-16\norient=texture",
|
||||
)]);
|
||||
let turned = || {
|
||||
Some(Axes {
|
||||
right: [0, 0, 1],
|
||||
up: [1, 0, 0],
|
||||
})
|
||||
};
|
||||
let north = |o: [i32; 3]| if o == [0, 0, -1] { 1 } else { 0 };
|
||||
let mut got = Vec::new();
|
||||
overlay.pick(
|
||||
2,
|
||||
7,
|
||||
Face::Up,
|
||||
[0, 64, 0],
|
||||
north,
|
||||
|| None,
|
||||
turned,
|
||||
|o| got.push(o.layer),
|
||||
);
|
||||
assert_eq!(got, [1015]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_panes_hide_the_strips_they_share() {
|
||||
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
|
||||
|
||||
@@ -31,6 +31,9 @@ struct Globals {
|
||||
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
|
||||
// [0] where the lift starts, [1] where it is full.
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -69,6 +72,8 @@ struct Out {
|
||||
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
|
||||
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
|
||||
@location(13) @interpolate(flat) tier: u32,
|
||||
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
|
||||
@location(14) scene: f32,
|
||||
}
|
||||
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
|
||||
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
@@ -107,6 +112,16 @@ fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
return min(rgb, vec3(1.0));
|
||||
}
|
||||
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
|
||||
fn hdr_scene(block: f32, sky: f32) -> f32 {
|
||||
let b = smoothstep(11.0, 15.0, block);
|
||||
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
|
||||
return max(b, s);
|
||||
}
|
||||
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
|
||||
fn hdr_scene_alpha(scene: f32) -> f32 {
|
||||
return 1.0 - 0.24 * scene;
|
||||
}
|
||||
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
|
||||
var out: Out;
|
||||
@@ -120,6 +135,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|
||||
|| overlay == 11u || overlay == 12u;
|
||||
out.light_color = select(lm, vec4(1.0), unlit);
|
||||
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
|
||||
if globals.colored_count.x > 0u && !unlit {
|
||||
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
|
||||
}
|
||||
@@ -328,8 +344,14 @@ fn shadow_color(in: Out) -> vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
let texel = textureSample(atlas, atlas_sampler, uv);
|
||||
if in.tier == 0u || texel.a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb)));
|
||||
if texel.a < 0.1 { discard; }
|
||||
// Fog hides a light source and the light on a surface as it hides everything else.
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
if in.tier == 0u {
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
|
||||
}
|
||||
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
|
||||
}
|
||||
fn brightness(c: vec3<f32>) -> f32 {
|
||||
let l = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
@@ -348,7 +370,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
// no longer shines; fs_hdr_mark then marks the light sources it holds.
|
||||
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
|
||||
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
|
||||
return vec4(0.0, 0.0, 0.0, 1.0);
|
||||
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
|
||||
}
|
||||
|
||||
fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@@ -375,8 +397,12 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
let added = color.rgb * color.a;
|
||||
// The sun at the brightest tier, the moon at its own (in.tier); geometry that sets
|
||||
// none (the End sky, the End flash) stays brightest.
|
||||
let keep = 1.0 - hdr_mark(select(in.tier, 4u, in.tier == 0u), brightness(added));
|
||||
return vec4(added, keep * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
|
||||
let tier = select(in.tier, 4u, in.tier == 0u);
|
||||
let y = brightness(added);
|
||||
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
|
||||
// or it would come out darker than the sky around it.
|
||||
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
|
||||
return vec4(added, 1.0 - hdr_mark(tier, y));
|
||||
}
|
||||
|
||||
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
|
||||
|
||||
@@ -947,6 +947,16 @@ impl Font {
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `Font.width(FormattedCharSequence)` of a sequence a component built
|
||||
/// with `FormattedCharSequence.forward`: every character is a glyph, a
|
||||
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
|
||||
pub fn width_drawn(&self, t: &Text) -> f32 {
|
||||
crate::bidi::visual(t)
|
||||
.chars()
|
||||
.map(|(c, s)| self.advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil()
|
||||
}
|
||||
/// `StringSplitter.splitLines`: each line and whether it continues a
|
||||
/// wrapped one. A line breaks at a `\n`, or once a character overflows
|
||||
/// `max_width` (never the first visible one) at the last space so far, or
|
||||
|
||||
@@ -329,6 +329,7 @@ struct Globals {
|
||||
/// HDR output's tier windows, low to very bright: where the lift starts, where it is
|
||||
/// full (`hdr::Hdr::windows`); zero without HDR.
|
||||
hdr_windows: [[f32; 4]; 2],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
|
||||
@@ -4009,6 +4010,10 @@ impl Renderer {
|
||||
pub fn section_count(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// The running native pack's `[[particle_lights]]`.
|
||||
pub fn particle_lights(&self) -> Option<&[horizon_shader_api::manifest::ParticleLight]> {
|
||||
self.native.as_ref().map(|n| n.particle_lights.as_slice())
|
||||
}
|
||||
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
|
||||
/// frame's `shadow_only` quads.
|
||||
pub fn native_shadow(&self) -> bool {
|
||||
@@ -4883,6 +4888,11 @@ impl Renderer {
|
||||
let world = pack_post::World {
|
||||
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
|
||||
lightmap: self.lightmap_view.clone(),
|
||||
gui_size: self
|
||||
.hud
|
||||
.atlas_texture
|
||||
.as_ref()
|
||||
.map_or([1, 1], |t| [t.width(), t.height()]),
|
||||
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
|
||||
buffers: if flat { &buffers[..1] } else { &buffers },
|
||||
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
|
||||
@@ -5355,6 +5365,8 @@ 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> {
|
||||
// Its steps as spans (HORIZON_SPIKES): each lap names the step that just ended.
|
||||
let mut laps = crate::spans::laps();
|
||||
let landing = Instant::now();
|
||||
self.land_frame();
|
||||
// Waiting for the previous frame (offscreen: at most two on the GPU), not work.
|
||||
@@ -5410,6 +5422,7 @@ impl Renderer {
|
||||
}
|
||||
self.surface_unconfigured = false;
|
||||
}
|
||||
laps.lap("r_prepare");
|
||||
// Frame generation: its present thread acquires and presents, this frame draws
|
||||
// into frames of its own. Not over menus that blur the frame, a shader pack's
|
||||
// own post passes, HDR or the title screen.
|
||||
@@ -5536,6 +5549,7 @@ impl Renderer {
|
||||
let views = self.views(frame);
|
||||
let pose = frame.bob * frame.spin;
|
||||
let view_proj = views.primary().view_proj(pose);
|
||||
laps.lap("r_acquire");
|
||||
// TAA runs where the world post pass does, not under a shader pack's own.
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
@@ -5675,9 +5689,21 @@ impl Renderer {
|
||||
0.0,
|
||||
],
|
||||
hdr_windows: self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
|
||||
hdr_light: [
|
||||
frame.sky.sky_light_factor,
|
||||
// The underwater overlay: the sky and fog are the water's own colour there.
|
||||
if frame.screen.iter().any(|q| q.world_tint) {
|
||||
0.0
|
||||
} else {
|
||||
frame.sky.sky_light_factor
|
||||
},
|
||||
0.0,
|
||||
0.0,
|
||||
],
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
laps.lap("r_targets");
|
||||
// GameRenderer.render3dHud: the hand's projection at hudFov, with the
|
||||
// bob in its pose rather than the projection (same product).
|
||||
let hand_projection = Mat4::from_cols_array(&crate::camera::hand_projection(
|
||||
@@ -5883,6 +5909,7 @@ impl Renderer {
|
||||
}
|
||||
self.origins.clear();
|
||||
let (sections, retiring) = (&self.sections, &self.lod_retiring);
|
||||
laps.lap("r_cameras");
|
||||
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`. Once
|
||||
// nothing is fading (the usual frame), every section is whole without a lookup.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
@@ -6064,6 +6091,7 @@ impl Renderer {
|
||||
self.draw_pages.extend_from_slice(&self.phase2_pages);
|
||||
let culling = cull.is_some();
|
||||
let solid_draws = self.draws.len() as u32;
|
||||
laps.lap("r_section_groups");
|
||||
// LevelRenderer: nearby sections each frame, plus a rotating eighth
|
||||
// (at least 15) of the visible list. Cached views avoid needless sorts.
|
||||
self.translucent_draws.clear();
|
||||
@@ -6261,6 +6289,7 @@ impl Renderer {
|
||||
(shadow_first + shadow_solid) as usize,
|
||||
);
|
||||
let shadow_translucent = self.draws.len() as u32 - shadow_first - shadow_solid;
|
||||
laps.lap("r_nearby");
|
||||
// Metal replays a multi-draw as one draw call per entry.
|
||||
self.section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal {
|
||||
(0..self.section_draws)
|
||||
@@ -6346,12 +6375,19 @@ impl Renderer {
|
||||
&self.queue,
|
||||
frame.entities,
|
||||
frame.entity_spans,
|
||||
frame.crumbling_sprite,
|
||||
&mut self.pack_entity_rest,
|
||||
);
|
||||
entities = &self.pack_entity_rest;
|
||||
}
|
||||
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
|
||||
// path.
|
||||
let hdr_lighting = self.native.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting;
|
||||
}
|
||||
let oit_on = self.improved_transparency
|
||||
&& !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
@@ -6387,6 +6423,7 @@ impl Renderer {
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads
|
||||
+ oit_entity_quads;
|
||||
laps.lap("r_draw_lists");
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads = world_quads - oit_entity_quads;
|
||||
@@ -6532,6 +6569,7 @@ impl Renderer {
|
||||
bytemuck::bytes_of(&uniform),
|
||||
);
|
||||
}
|
||||
laps.lap("r_materials");
|
||||
// Every upload before the frame's first pass: a pass ends wgpu-core's
|
||||
// command buffer, so copies between passes would need one more.
|
||||
if !frame.screen.is_empty() {
|
||||
@@ -6658,6 +6696,7 @@ impl Renderer {
|
||||
.flatten();
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
let keep_depth = pack_reads_world
|
||||
|| (taa && self.world_post.taa)
|
||||
@@ -6789,6 +6828,7 @@ impl Renderer {
|
||||
}
|
||||
};
|
||||
let sky_visible = sky_open(frame);
|
||||
laps.lap("r_world_pass");
|
||||
// One of Horizon's own packs: this frame's inputs; in a timed frame its sky draws in a
|
||||
// pass of its own, so that it has a time of its own.
|
||||
let mut native_sky_pass = false;
|
||||
@@ -6927,6 +6967,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
sky.draw(&mut pass, &native.frame);
|
||||
}
|
||||
laps.lap("r_pack_inputs");
|
||||
// The water mirror, for one of Horizon's own packs that reads it: the world as a
|
||||
// camera mirrored about the water plane sees it, into a half-resolution target the
|
||||
// material stage samples as `reflection`.
|
||||
@@ -7064,6 +7105,7 @@ impl Renderer {
|
||||
};
|
||||
native.frame.fit_surface(&self.device, size);
|
||||
}
|
||||
laps.lap("r_mirror");
|
||||
// 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
|
||||
@@ -8054,6 +8096,7 @@ impl Renderer {
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
laps.lap("r_passes");
|
||||
// Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame.
|
||||
let shown_texture = match &fg_frames {
|
||||
Some((_, generated)) if fg_made && frame_gen::generated_only() => generated,
|
||||
|
||||
@@ -149,6 +149,9 @@ pub struct Hdr {
|
||||
pub ui: f32,
|
||||
/// Which pixels of a light source shine (`Masks`).
|
||||
pub masks: Masks,
|
||||
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
|
||||
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
pub lighting: bool,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
@@ -293,6 +296,7 @@ impl Hdr {
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
masks: MASKS,
|
||||
lighting: true,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
@@ -539,6 +543,11 @@ impl Hdr {
|
||||
]);
|
||||
params[5] = self.black.clamp(0.0, paper);
|
||||
params[6] = self.ui.clamp(0.25, 1.0);
|
||||
params[7] = if self.lighting {
|
||||
scene_gain(self.peak, self.paper_white)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
}
|
||||
@@ -618,6 +627,12 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
@@ -670,3 +685,18 @@ mod tests {
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lighting_tests {
|
||||
use super::scene_gain;
|
||||
|
||||
#[test]
|
||||
fn the_lighting_scales_with_the_headroom() {
|
||||
// Enough headroom: the full strength, whatever the paper white.
|
||||
assert_eq!(scene_gain(1000.0, 430.0), 4.0);
|
||||
assert_eq!(scene_gain(1060.0, 203.0), 4.0);
|
||||
// Little headroom, and none.
|
||||
assert_eq!(scene_gain(600.0, 400.0), 2.625);
|
||||
assert_eq!(scene_gain(200.0, 480.0), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ struct HorizonMaterialOut {
|
||||
@location(8) @interpolate(flat) face: u32,
|
||||
@location(9) @interpolate(flat) fluid_quad: u32,
|
||||
@location(10) @interpolate(flat) emission: f32,
|
||||
// terrain.wgsl's `emission`, for HDR output's mark.
|
||||
@location(11) @interpolate(flat) hdr_code: u32,
|
||||
}
|
||||
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
|
||||
@@ -67,7 +69,9 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
out.fluid_quad = u32(fluid);
|
||||
// The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
|
||||
// face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
|
||||
let tier = 15u - (flags & 15u);
|
||||
out.hdr_code = 15u - (flags & 15u);
|
||||
// Codes above 4 are the same tiers, shining all over rather than in their bright parts.
|
||||
let tier = select(out.hdr_code, out.hdr_code - 4u, out.hdr_code > 4u);
|
||||
out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
|
||||
return out;
|
||||
}
|
||||
@@ -145,7 +149,8 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
|
||||
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;
|
||||
in.face, fluid, in.emission, normal, tangent,
|
||||
horizon_terrain_native_colored_light(in.position))).color;
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@@ -154,15 +159,33 @@ struct HorizonMaterialSolid {
|
||||
// stage's `surface_at` compares with the final depth.
|
||||
@location(1) surface: vec4<f32>,
|
||||
}
|
||||
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
|
||||
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
|
||||
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
if in.hdr_code > 0u {
|
||||
let whole = in.hdr_code > 4u;
|
||||
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(horizon_terrain_emission_thresholds,
|
||||
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
|
||||
let y = horizon_terrain_brightness(texel.rgb);
|
||||
if whole || y >= t.r {
|
||||
return horizon_terrain_hdr_mark(tier, y);
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
let color = horizon_material_color(in, false);
|
||||
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
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);
|
||||
let mark = horizon_material_mark(in, texel);
|
||||
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
|
||||
out.surface = vec4(horizon_surface.xyz, in.clip.z);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct NativePack {
|
||||
pub weather: Option<wgpu::RenderPipeline>,
|
||||
/// The draw of the shadow map the stages read, if the pack asks for one.
|
||||
pub shadow: Option<NativeShadow>,
|
||||
/// `[[particle_lights]]`.
|
||||
pub particle_lights: Vec<horizon_shader_api::manifest::ParticleLight>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<PassTiming>,
|
||||
}
|
||||
@@ -133,6 +135,7 @@ impl NativePack {
|
||||
labels.extend(post.labels().into_iter().map(str::to_owned));
|
||||
}
|
||||
let pack = Self {
|
||||
particle_lights: report.manifest.particle_lights.clone(),
|
||||
frame,
|
||||
sky,
|
||||
material,
|
||||
|
||||
@@ -319,6 +319,7 @@ impl PackEntities {
|
||||
entities: &[Vertex],
|
||||
spans: &[EntitySpan],
|
||||
drawn: [bool; PROGRAMS],
|
||||
crack: ([f32; 4], [u32; 2]),
|
||||
shadow: Casters,
|
||||
rest: &mut Vec<Vertex>,
|
||||
) {
|
||||
@@ -330,7 +331,9 @@ impl PackEntities {
|
||||
rest.extend_from_slice(&entities[..first.min(entities.len())]);
|
||||
for (span, range) in runs(entities.len(), spans) {
|
||||
let run = &entities[range];
|
||||
let program = program_of(&span).filter(|_| plain(run));
|
||||
let program = program_of(&span).filter(|&p| {
|
||||
plain(run) || (p == DESTROY && run.iter().all(|v| matches!(v.layer, 11 | 12)))
|
||||
});
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
let casts = program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
@@ -341,6 +344,9 @@ impl PackEntities {
|
||||
};
|
||||
let start = self.scratch.len() as u32;
|
||||
self.scratch.extend_from_slice(run);
|
||||
if program == DESTROY {
|
||||
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
|
||||
}
|
||||
let (entity_id, block_id) = match program {
|
||||
BLOCK => (0, span.id),
|
||||
_ => (span.id, 0),
|
||||
@@ -419,7 +425,8 @@ pub const ENTITY: usize = 0;
|
||||
pub const BLOCK: usize = 1;
|
||||
pub const TRANSLUCENT: usize = 2;
|
||||
pub const PARTICLE: usize = 3;
|
||||
pub const PROGRAMS: usize = 4;
|
||||
pub const DESTROY: usize = 4;
|
||||
pub const PROGRAMS: usize = 5;
|
||||
|
||||
/// Which program draws `span`'s run, given which of them (or their fallbacks) the pack has;
|
||||
/// `None`: Horizon draws it.
|
||||
@@ -440,6 +447,8 @@ pub fn program_of(span: &EntitySpan) -> Option<usize> {
|
||||
BLOCK
|
||||
} else if span.stage == render_stage("PARTICLES") {
|
||||
PARTICLE
|
||||
} else if span.stage == render_stage("DESTROY") {
|
||||
DESTROY
|
||||
} else {
|
||||
return None;
|
||||
})
|
||||
@@ -461,6 +470,16 @@ pub fn runs(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn destroy_stage_uses_the_pack_only_when_a_program_is_available() {
|
||||
let destroy = EntitySpan {
|
||||
stage: render_stage("DESTROY"),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(drawn_by_pack(&destroy, [true; PROGRAMS]).is_some());
|
||||
assert_eq!(drawn_by_pack(&destroy, [false; PROGRAMS]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_split_the_list_between_pack_and_horizon() {
|
||||
let v = |layer| Vertex {
|
||||
@@ -483,15 +502,21 @@ mod tests {
|
||||
let all = [true; PROGRAMS];
|
||||
assert_eq!(drawn_by_pack(&spans[0], all), Some(ENTITY));
|
||||
assert_eq!(drawn_by_pack(&spans[1], all), Some(PARTICLE));
|
||||
assert_eq!(drawn_by_pack(&spans[1], [true, true, true, false]), None);
|
||||
assert_eq!(drawn_by_pack(&spans[2], [true, false, true, true]), None);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[1], [true, true, true, false, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
drawn_by_pack(&spans[2], [true, false, true, true, true]),
|
||||
None
|
||||
);
|
||||
assert_eq!(drawn_by_pack(&spans[2], all), Some(BLOCK));
|
||||
let shadow = EntitySpan {
|
||||
translucent: true,
|
||||
..spans[0]
|
||||
};
|
||||
assert_eq!(drawn_by_pack(&shadow, all), Some(TRANSLUCENT));
|
||||
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), None);
|
||||
assert_eq!(drawn_by_pack(&span(0, "DESTROY", 0), all), Some(DESTROY));
|
||||
// A beacon beam's quads stay Horizon's.
|
||||
assert!(plain(&[v(0), v(0), v(0), v(0), v(1)]));
|
||||
assert!(!plain(&[v(0), v(0), v(0), v(0), v(6)]));
|
||||
@@ -523,3 +548,53 @@ mod tests {
|
||||
assert!(!some.cast(&spans[2], BLOCK) && some.cast(&lit, BLOCK));
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode the destroy-stage origin stored in the light bytes and project one continuous
|
||||
/// sprite over each model face. Pack geometry uses ordinary atlas UVs, not Horizon's overlay codes.
|
||||
fn project_cracks(vertices: &mut [Vertex], sprite: [f32; 4], atlas: [u32; 2]) {
|
||||
let dims = [atlas[0].max(1) as f32, atlas[1].max(1) as f32];
|
||||
for quad in vertices.as_chunks_mut::<4>().0 {
|
||||
let origin = [
|
||||
(quad[0].light & 65535) as f32 / 65535.0,
|
||||
(quad[0].light >> 16) as f32 / 65535.0,
|
||||
];
|
||||
let origin = [0, 1].map(|i| (origin[i] * dims[i]).round() / dims[i]);
|
||||
let min = [0, 1]
|
||||
.map(|i| (quad.iter().map(|v| v.uv[i]).fold(f32::INFINITY, f32::min) + 0.0001).floor());
|
||||
for v in quad {
|
||||
v.uv =
|
||||
[0, 1].map(|i| origin[i] + (v.uv[i] - min[i]).clamp(0.001, 0.999) * sprite[i + 2]);
|
||||
v.layer = 0;
|
||||
v.light = 0x0000_f0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod crack_projection_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn crack_uvs_keep_a_full_sprite_and_decode_each_stage() {
|
||||
let mut vertices: Vec<Vertex> = [[-1.0, -1.0], [-1.0, 0.0], [0.0, 0.0], [0.0, -1.0]]
|
||||
.into_iter()
|
||||
.map(|uv| Vertex {
|
||||
uv,
|
||||
layer: 11,
|
||||
light: crate::breaking::stage_light([0.25, 0.5]),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect();
|
||||
project_cracks(
|
||||
&mut vertices,
|
||||
[0.0, 0.0, 16.0 / 4096.0, 16.0 / 9216.0],
|
||||
[4096, 9216],
|
||||
);
|
||||
assert!(
|
||||
vertices
|
||||
.iter()
|
||||
.all(|v| v.layer == 0 && v.uv[0] >= 0.25 && v.uv[0] < 0.25 + 16.0 / 4096.0)
|
||||
);
|
||||
assert!((vertices[2].uv[0] - vertices[0].uv[0]) > 15.0 / 4096.0);
|
||||
assert!((vertices[1].uv[1] - vertices[0].uv[1]) > 15.0 / 9216.0);
|
||||
}
|
||||
}
|
||||
@@ -408,6 +408,7 @@ pub(super) struct World<'a> {
|
||||
pub block_ids: &'a [i32],
|
||||
/// The atlas of the sun and moon's sprites.
|
||||
pub celestial: Option<wgpu::TextureView>,
|
||||
pub gui_size: [u32; 2],
|
||||
/// Set 1 of the clouds' draw: their offset and palette.
|
||||
pub clouds: Option<&'a wgpu::BindGroupLayout>,
|
||||
/// The game can hand its rain and snow to the pack.
|
||||
@@ -510,6 +511,7 @@ pub struct PackPost {
|
||||
/// with the page buffer it was made for.
|
||||
block_ids: Option<(wgpu::BindGroupLayout, wgpu::Buffer)>,
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
gui_size: [u32; 2],
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
|
||||
@@ -1341,11 +1343,16 @@ fn build_pass(
|
||||
} else if let Some(&buffer) = p.buffers.get(l) {
|
||||
let format = formats[buffer as usize];
|
||||
let mut state = target(format);
|
||||
if let (Some(t), Some(b)) = (&mut state, p.blend)
|
||||
&& format
|
||||
.guaranteed_format_features(features)
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
if let (Some(t), Some(b)) = (
|
||||
&mut state,
|
||||
p.buffer_blends
|
||||
.iter()
|
||||
.find(|(n, _)| *n == buffer)
|
||||
.map_or(p.blend, |(_, b)| *b),
|
||||
) && format
|
||||
.guaranteed_format_features(features)
|
||||
.flags
|
||||
.contains(wgpu::TextureFormatFeatureFlags::BLENDABLE)
|
||||
{
|
||||
t.blend = Some(blend_state(b));
|
||||
}
|
||||
@@ -1433,7 +1440,15 @@ fn build_pass(
|
||||
depth_write_enabled: Some(g.depth_write),
|
||||
depth_compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
bias: if p.name == "gbuffers_damagedblock" {
|
||||
wgpu::DepthBiasState {
|
||||
constant: -10,
|
||||
slope_scale: -1.0,
|
||||
clamp: 0.0,
|
||||
}
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
@@ -1893,8 +1908,15 @@ impl PackPost {
|
||||
post.block,
|
||||
post.entities_translucent,
|
||||
post.particles,
|
||||
post.damaged_block,
|
||||
];
|
||||
let late = [
|
||||
false,
|
||||
false,
|
||||
post.translucent_late,
|
||||
post.particles_late,
|
||||
false,
|
||||
];
|
||||
let late = [false, false, post.translucent_late, post.particles_late];
|
||||
let shadow_only = (post.shadow_only_player, post.shadow_only_lit_blocks);
|
||||
let shadow_programs = [post.shadow_entities, post.shadow_block];
|
||||
let entity_vertices = (programs.iter().chain(&shadow_programs))
|
||||
@@ -1910,8 +1932,21 @@ impl PackPost {
|
||||
cull: false,
|
||||
});
|
||||
let entity_passes = programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)))
|
||||
p.zip(entity_geometry.as_ref()).and_then(|(p, g)| {
|
||||
if p.name == "gbuffers_damagedblock" {
|
||||
let decal = Geometry {
|
||||
buffers: g.buffers,
|
||||
table: g.table,
|
||||
depth_write: false,
|
||||
atlas: g.atlas.clone(),
|
||||
ids: g.ids,
|
||||
cull: false,
|
||||
};
|
||||
build(p, Some(&decal))
|
||||
} else {
|
||||
build(p, Some(g))
|
||||
}
|
||||
})
|
||||
});
|
||||
let shadow_entities = shadow_programs.map(|p| {
|
||||
p.zip(entity_geometry.as_ref())
|
||||
@@ -2243,6 +2278,7 @@ impl PackPost {
|
||||
draws,
|
||||
block_ids,
|
||||
page_groups: HashMap::new(),
|
||||
gui_size: world.gui_size,
|
||||
timing: None,
|
||||
center_depth: None,
|
||||
custom,
|
||||
@@ -2868,6 +2904,7 @@ impl PackPost {
|
||||
queue: &wgpu::Queue,
|
||||
entities: &[super::Vertex],
|
||||
spans: &[super::pack_draw::EntitySpan],
|
||||
crumbling: [f32; 4],
|
||||
rest: &mut Vec<super::Vertex>,
|
||||
) {
|
||||
let shadow = self.shadow_casters();
|
||||
@@ -2884,6 +2921,7 @@ impl PackPost {
|
||||
entities,
|
||||
spans,
|
||||
drawn,
|
||||
(crumbling, self.gui_size),
|
||||
shadow,
|
||||
rest,
|
||||
);
|
||||
|
||||
@@ -404,9 +404,9 @@ impl FrameUniforms {
|
||||
frame.depth_far,
|
||||
);
|
||||
let camera = frame.camera.as_vec3();
|
||||
let first_frame = self.previous.is_none();
|
||||
let (previous_exact, previous_view, previous_projection) =
|
||||
self.previous
|
||||
.unwrap_or((glam::DVec3::ZERO, Mat4::ZERO, Mat4::ZERO));
|
||||
self.previous.unwrap_or((frame.camera, view, projection));
|
||||
let previous_camera = previous_exact.as_vec3();
|
||||
self.previous = Some((frame.camera, view, projection));
|
||||
// The camera's whole blocks and the rest, exact however far from the origin.
|
||||
@@ -436,6 +436,10 @@ impl FrameUniforms {
|
||||
};
|
||||
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
|
||||
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
|
||||
if first_frame {
|
||||
self.wetness = inputs.rain;
|
||||
self.eye_smooth = eye;
|
||||
}
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
|
||||
}
|
||||
@@ -849,3 +853,147 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod initial_history_tests {
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
fn float(u: &FrameUniforms, name: &str) -> [f32; 16] {
|
||||
match u.values[index(name).unwrap()] {
|
||||
Value::F(v) => v,
|
||||
_ => panic!("float uniform expected"),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn first_history_is_current_then_tracks_the_actual_previous_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let mut f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
f.camera = DVec3::new(1024.25, -3.5, 11.75);
|
||||
f.yaw = 21.0;
|
||||
f.pitch = -16.0;
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
assert_eq!(
|
||||
float(&u, "previousCameraPosition"),
|
||||
float(&u, "cameraPosition")
|
||||
);
|
||||
for (prior, current) in [
|
||||
("gbufferPreviousProjection", "gbufferProjection"),
|
||||
("gbufferPreviousModelView", "gbufferModelView"),
|
||||
] {
|
||||
assert_eq!(float(&u, prior), float(&u, current));
|
||||
assert!(float(&u, prior).iter().any(|v| *v != 0.0));
|
||||
}
|
||||
let camera = float(&u, "cameraPosition");
|
||||
let view = float(&u, "gbufferModelView");
|
||||
let projection = float(&u, "gbufferProjection");
|
||||
f.camera = DVec3::new(-100.0, 20.0, 40.0);
|
||||
f.yaw = 90.0;
|
||||
f.pitch = 50.0;
|
||||
f.fov_degrees = 100.0;
|
||||
u.update(&f, &PackInputs::default(), [1000, 500]);
|
||||
assert_eq!(float(&u, "previousCameraPosition"), camera);
|
||||
assert_eq!(float(&u, "gbufferPreviousModelView"), view);
|
||||
assert_eq!(float(&u, "gbufferPreviousProjection"), projection);
|
||||
assert_ne!(
|
||||
float(&u, "previousCameraPosition"),
|
||||
float(&u, "cameraPosition")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
let mut input = PackInputs {
|
||||
rain: 0.75,
|
||||
eye_light: [3, 14],
|
||||
..Default::default()
|
||||
};
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert_eq!(float(&u, "wetness")[0], 0.75);
|
||||
match u.values[index("eyeBrightnessSmooth").unwrap()] {
|
||||
Value::I(v) => assert_eq!(&v[..2], &[48, 224]),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
input.rain = 0.0;
|
||||
input.eye_light = [0, 0];
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!(float(&u, "wetness")[0] > 0.0);
|
||||
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
|
||||
}
|
||||
fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
eye: glam::DVec3::ZERO,
|
||||
eye_rotation: [0.0; 2],
|
||||
first_person: true,
|
||||
shadow_scene_revision: (0, 0),
|
||||
shadow_cache: false,
|
||||
water_plane: None,
|
||||
weather: &[],
|
||||
weather_time: crate::weather::Time::new(0, 0.0),
|
||||
camera: glam::DVec3::new(0.5, 100.0, 0.5),
|
||||
yaw: 0.0,
|
||||
pitch: 10.0,
|
||||
fov_degrees: 85.0,
|
||||
bob: glam::Mat4::IDENTITY,
|
||||
spin: glam::Mat4::IDENTITY,
|
||||
sky: crate::environment::Sky::default(),
|
||||
distance: 12.0,
|
||||
detail_distance: 12.0,
|
||||
depth_far: 1000.0,
|
||||
underwater: false,
|
||||
cave_culling: false,
|
||||
title: true,
|
||||
sky_blocked: false,
|
||||
skybox: false,
|
||||
end_sky: false,
|
||||
end_sky_sprite: None,
|
||||
environment_fog: None,
|
||||
water_fog: [0.0; 3],
|
||||
fog: None,
|
||||
dark_disc: false,
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
hud,
|
||||
glint_alpha: 0.75,
|
||||
glint_ticks: 0.0,
|
||||
nether_lighting: false,
|
||||
entities: &[],
|
||||
entity_motion: &[],
|
||||
colored_lights: &[],
|
||||
emissive_layers: &[],
|
||||
shadow_only: &[],
|
||||
entity_spans: &[],
|
||||
glowing: &[],
|
||||
dynamic_lights: &[],
|
||||
clouds: None,
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
end_portal_sprites: [[0.0; 4]; 2],
|
||||
crumbling_sprite: [0.0; 4],
|
||||
ticks: 0,
|
||||
darkness: (0.0, 0.0),
|
||||
night_vision: 0.0,
|
||||
block_flicker: 0.0,
|
||||
boss_darken: 0.0,
|
||||
menu_blur: None,
|
||||
chunk_fade: 0.0,
|
||||
post_effects: crate::post_chain::Request::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4, Vec2};
|
||||
|
||||
/// The least a world is scaled to when memory runs out.
|
||||
const MIN_SCALE: f32 = 0.25;
|
||||
/// The TAA history format: 8 bits would band as frames accumulate.
|
||||
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
@@ -322,12 +324,65 @@ impl Post {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
// The old target goes first: a slider drag would otherwise hold the old and the new
|
||||
// targets together, and the sum is what runs a small GPU out of memory.
|
||||
loop {
|
||||
let (size, output) = self.sizes(window);
|
||||
let history_size = self.taa_on.then_some(output);
|
||||
if self
|
||||
.target
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.size == size && t.taa.as_ref().map(|h| h.size) == history_size)
|
||||
{
|
||||
break;
|
||||
}
|
||||
self.target = None;
|
||||
// Errors raised are fatal, so catch an out-of-memory one and scale down. A texture
|
||||
// that failed is invalid, so its view fails validation too: catch that as well.
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
|
||||
let target = self.build_target(device, size, history_size);
|
||||
let error = pollster::block_on(memory.pop()).or(pollster::block_on(validation.pop()));
|
||||
if error.is_none() {
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(target);
|
||||
break;
|
||||
}
|
||||
drop(target);
|
||||
if self.scale <= MIN_SCALE {
|
||||
log::warn!(
|
||||
"Horizon renderer: no video memory for the scaled world at {size:?} ({}); drawing at the window's size",
|
||||
error.map_or_else(String::new, |e| e.to_string())
|
||||
);
|
||||
self.scale = 1.0;
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
let reduced = (self.scale * 0.75).max(MIN_SCALE);
|
||||
log::warn!(
|
||||
"Horizon renderer: out of video memory for the scaled world at {size:?}; resolution scale {:.2} -> {reduced:.2} ({})",
|
||||
self.scale,
|
||||
error.map_or_else(String::new, |e| e.to_string())
|
||||
);
|
||||
self.scale = reduced;
|
||||
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets at `size`, and TAA's history when `history_size`
|
||||
/// is set.
|
||||
fn build_target(
|
||||
&self,
|
||||
device: &wgpu::Device,
|
||||
size: [u32; 2],
|
||||
history_size: Option<[u32; 2]>,
|
||||
) -> WorldTarget {
|
||||
{
|
||||
let make_tex = |label, size: [u32; 2], format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -403,19 +458,14 @@ impl Post {
|
||||
show,
|
||||
})
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
self.target = Some(WorldTarget {
|
||||
WorldTarget {
|
||||
size,
|
||||
color,
|
||||
depth,
|
||||
group,
|
||||
taa,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.target
|
||||
.as_ref()
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// The size of the world's own target, when it has one.
|
||||
|
||||
@@ -2226,4 +2226,112 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Horizon's graphics captions are English sentences that the language table keys by
|
||||
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
|
||||
/// English in every language. Every text the three pages show, with each setting cycled
|
||||
/// through its values (#1927).
|
||||
#[test]
|
||||
fn every_graphics_text_is_in_the_language_table() {
|
||||
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
|
||||
for hdr in [false, true] {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = hdr;
|
||||
s.horizon.taa = true;
|
||||
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
|
||||
for _ in 0..8 {
|
||||
for row in extra_rows(level, &s) {
|
||||
match row {
|
||||
Row::Header(name) => {
|
||||
lang::get(name);
|
||||
}
|
||||
Row::Setting(row) => {
|
||||
row.item.label(&s);
|
||||
let text = description(row.item, &s, row.text);
|
||||
lang::get(text);
|
||||
lang::get(row.cost.label());
|
||||
if let Some(why) = row.item.blocked(&s) {
|
||||
lang::get(why);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for row in extra_rows(level, &s) {
|
||||
if let Row::Setting(Setting {
|
||||
item: Item::Horizon(h),
|
||||
..
|
||||
}) = row
|
||||
{
|
||||
h.cycle(&mut s, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for button in ["Advanced...", "Simple...", "Done"] {
|
||||
lang::get(button);
|
||||
}
|
||||
});
|
||||
// `labelled` also looks up values that are already words in the language ("An") or
|
||||
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
|
||||
// a name.
|
||||
let missed: Vec<_> = missed
|
||||
.into_iter()
|
||||
.filter(|m| !m.starts_with(|c: char| c.is_ascii_digit()) && !m.starts_with("Auto ("))
|
||||
.filter(|m| !["An", "Aus", "FSR 3.1", "key.keyboard.unknown"].contains(&m.as_str()))
|
||||
.collect();
|
||||
assert_eq!(missed, Vec::<String>::new());
|
||||
}
|
||||
/// Leaf Culling, Frame Generation and Shadow Cache came with English captions only (#1927).
|
||||
#[test]
|
||||
fn newer_graphics_settings_are_translated() {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.leaf_culling = LeafCullingMode::Hollow;
|
||||
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
|
||||
for (code, leaf, entity, leaf_text) in [
|
||||
(
|
||||
"de_de",
|
||||
"Laub-Culling: Hohl",
|
||||
"Objekt-Culling: Asynchron",
|
||||
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
|
||||
),
|
||||
(
|
||||
"pl_pl",
|
||||
"Pomijanie liści: Puste",
|
||||
"Pomijanie bytów: Asynchroniczne",
|
||||
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
|
||||
),
|
||||
(
|
||||
"uk_ua",
|
||||
"Відсікання листя: Порожнє",
|
||||
"Відсік. сутностей: Асинхронно",
|
||||
"Прибирає грані між листям: швидше, але прогалини прозорі.",
|
||||
),
|
||||
(
|
||||
"zh_cn",
|
||||
"树叶剔除: 镂空",
|
||||
"实体剔除: 异步",
|
||||
"省略树叶之间的面:更快,但缝隙会透视。",
|
||||
),
|
||||
(
|
||||
"fr_fr",
|
||||
"Occlusion feuillage: Creux",
|
||||
"Occlusion des entités: Asynchrone",
|
||||
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
|
||||
),
|
||||
] {
|
||||
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
|
||||
let item = |h| Item::Horizon(h);
|
||||
(
|
||||
item(H::LeafCulling).label(&s).plain(),
|
||||
item(H::EntityCulling).label(&s).plain(),
|
||||
lang::get(description(item(H::LeafCulling), &s, "")),
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
shown,
|
||||
(leaf.into(), entity.into(), leaf_text.into()),
|
||||
"{code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ struct Params {
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
|
||||
scene: f32,
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// its fully lifted pixels get.
|
||||
share: vec4<f32>,
|
||||
@@ -53,7 +55,7 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
|
||||
return true;
|
||||
}
|
||||
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
|
||||
}
|
||||
|
||||
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
|
||||
@@ -168,8 +170,23 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
// lower the brighter, and where in the band how much (hdr_mark). The hand
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
var lifted = false;
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75
|
||||
&& (hand || (!covered && shows_world(c, w.rgb))) {
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(c, w.rgb)));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail.
|
||||
if shows && w.a >= 0.75 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * params.peak, 1.0);
|
||||
var out = v;
|
||||
if v > knee && params.peak > knee {
|
||||
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), v > knee);
|
||||
}
|
||||
if shows && w.a < 0.75 {
|
||||
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod biome;
|
||||
pub mod block_edit;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod border_render;
|
||||
pub mod breaking;
|
||||
|
||||
@@ -20,7 +20,7 @@ use horizon_assets::model::Direction as ModelDirection;
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
use horizon_optifine::ctm::connect::Face as CtmFace;
|
||||
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
|
||||
use horizon_world::{
|
||||
block_render::FaceVisibility,
|
||||
fluid::{FluidKind, FluidState},
|
||||
@@ -1379,6 +1379,14 @@ impl Mesher<'_> {
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|
||||
|| {
|
||||
if state.pane {
|
||||
Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize]))
|
||||
} else {
|
||||
CtmAxes::texture(&q.positions, &q.uv)
|
||||
}
|
||||
},
|
||||
|overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
|
||||
@@ -28,10 +28,18 @@ pub fn load(root: &Path, sprites: &mut Sprites) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A connected-texture tile's sprite name without `.png`: tiles outside `textures/` are named by
|
||||
/// their resource path (`Sprites::insert_file`, which only they use), block sprites by an id.
|
||||
fn tile_file(name: &str) -> Option<&str> {
|
||||
name.strip_prefix("assets/")?;
|
||||
name.strip_suffix(".png")
|
||||
}
|
||||
|
||||
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each
|
||||
/// `<ns>:block/<name>` whose `<name><suffix>.png` sits beside it, the overlay is loaded too and
|
||||
/// paired with it, first by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
|
||||
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
|
||||
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// the table). A pack without such files adds nothing.
|
||||
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
let suffix = horizon_optifine::emissive::suffix(root);
|
||||
@@ -41,21 +49,34 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
for layer in sprites.layers.values() {
|
||||
*sharing.entry(*layer).or_default() += 1;
|
||||
}
|
||||
let bases: Vec<String> = sprites
|
||||
let mut bases: Vec<String> = sprites
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|(name, layer)| {
|
||||
sharing[layer] == 1
|
||||
&& name
|
||||
&& (name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
// Blocks before tiles: a pack's many tiles must not crowd vanilla blocks' overlays out.
|
||||
bases.sort_by_key(|name| (tile_file(name).is_some(), name.clone()));
|
||||
let mut pairs = Vec::new();
|
||||
for base in bases {
|
||||
let overlay = format!("{base}{suffix}");
|
||||
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
|
||||
// A tile is named by its file, a block sprite by its id.
|
||||
let (overlay, file) = match tile_file(&base) {
|
||||
Some(stem) => {
|
||||
let overlay = format!("{stem}{suffix}.png");
|
||||
(overlay.clone(), root.join(overlay))
|
||||
}
|
||||
None => {
|
||||
let overlay = format!("{base}{suffix}");
|
||||
let file = crate::atlas_sources::texture_path(root, &overlay, "textures");
|
||||
(overlay, file)
|
||||
}
|
||||
};
|
||||
if !std::fs::metadata(&file).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
@@ -71,7 +92,12 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
continue;
|
||||
}
|
||||
// A file that doesn't load would glow as the missing texture: no pair instead.
|
||||
match sprites.try_insert(root, &overlay) {
|
||||
let loaded = if overlay.starts_with("assets/") {
|
||||
sprites.insert_file(root, &overlay)
|
||||
} else {
|
||||
sprites.try_insert(root, &overlay)
|
||||
};
|
||||
match loaded {
|
||||
Ok(_) => pairs.push((base, overlay)),
|
||||
Err(e) => log::warn!("Horizon: emissive overlay {overlay} skipped: {e}"),
|
||||
}
|
||||
@@ -192,13 +218,40 @@ mod tests {
|
||||
let props = root.join("assets/minecraft/optifine/emissive.properties");
|
||||
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
|
||||
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
|
||||
// Connected-texture tiles pair with the overlay beside them.
|
||||
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
|
||||
png(&tiles.join("0.png"), 230);
|
||||
png(&tiles.join("0_glow.png"), 240);
|
||||
png(&tiles.join("1.png"), 250);
|
||||
// A tile outside `ctm/` (`tiles=~/glass/2`) is a tile all the same.
|
||||
png(&root.join("assets/minecraft/optifine/glass/2.png"), 245);
|
||||
png(
|
||||
&root.join("assets/minecraft/optifine/glass/2_glow.png"),
|
||||
246,
|
||||
);
|
||||
for tile in ["ctm/lamp/0", "ctm/lamp/1", "glass/2"] {
|
||||
sprites
|
||||
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
|
||||
.unwrap();
|
||||
}
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[(
|
||||
"minecraft:block/deepslate".into(),
|
||||
"minecraft:block/deepslate_glow".into()
|
||||
)]
|
||||
// Blocks first, then tiles.
|
||||
[
|
||||
(
|
||||
"minecraft:block/deepslate".into(),
|
||||
"minecraft:block/deepslate_glow".into()
|
||||
),
|
||||
(
|
||||
"assets/minecraft/optifine/ctm/lamp/0.png".into(),
|
||||
"assets/minecraft/optifine/ctm/lamp/0_glow.png".into()
|
||||
),
|
||||
(
|
||||
"assets/minecraft/optifine/glass/2.png".into(),
|
||||
"assets/minecraft/optifine/glass/2_glow.png".into()
|
||||
),
|
||||
]
|
||||
);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
@@ -5368,6 +5368,61 @@ mod tests {
|
||||
assert!(ui.resource_error.is_none());
|
||||
}
|
||||
|
||||
/// The texts of an options page, as laid out in `code`'s language, and the keys it lacked.
|
||||
fn page_texts(code: &str, pages: &[Page]) -> (Vec<String>, Vec<String>) {
|
||||
let s = Settings::default();
|
||||
lang::scoped(lang::Language::load(&[], code), || {
|
||||
let mut texts = Vec::new();
|
||||
for page in pages {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(*page);
|
||||
for w in ui.layout(&s, 480.0, 4000.0).0 {
|
||||
match w.kind {
|
||||
WKind::Button { label: t, .. }
|
||||
| WKind::Slider { label: t, .. }
|
||||
| WKind::Text { text: t, .. } => texts.push(t.plain()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
texts
|
||||
})
|
||||
}
|
||||
|
||||
/// Horizon's own options pages said "Splitscreen...", "Hide block-break particles" and
|
||||
/// "Hotbar: Previous Slot" in every language (#1927).
|
||||
#[test]
|
||||
fn horizon_option_pages_follow_the_language() {
|
||||
let (texts, missed) = page_texts("de_de", &[Page::Horizon, Page::Extras, Page::PadBinds]);
|
||||
for want in [
|
||||
"Splitscreen …",
|
||||
"Partikel",
|
||||
"Blockpartikel weg: Aus",
|
||||
"Weicher Chat: Aus",
|
||||
"Nachrichtenmarkierung: An",
|
||||
"Kanäle für UI-Töne: Aus",
|
||||
"Schnellzugriffsleiste: Vorheriger Platz",
|
||||
"Schnellzugriffsleiste: Nächster Platz",
|
||||
] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
|
||||
}
|
||||
// Every key these pages ask for is in the table; vanilla's own keys (`options.*`,
|
||||
// `key.*`: dotted ids without spaces) come from the prepared resources, which this
|
||||
// language does not have.
|
||||
let missed: Vec<_> = missed
|
||||
.into_iter()
|
||||
.filter(|k| k.starts_with("horizon.") || k.contains(' ') || !k.contains('.'))
|
||||
.collect();
|
||||
assert_eq!(missed, Vec::<String>::new());
|
||||
let (texts, _) = page_texts("pl_pl", &[Page::Extras, Page::PadBinds]);
|
||||
for want in [
|
||||
"Ukryj cząstki bloków: Wył.",
|
||||
"Pasek szybkiego wyboru: poprzednie pole",
|
||||
] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
|
||||
let atlas = atlas();
|
||||
|
||||
@@ -4093,6 +4093,47 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// The particles that `entries` name, as lights: where each is this frame, its entry's colour
|
||||
/// as RGB and reach, and its alpha as the intensity.
|
||||
pub fn lights(
|
||||
&self,
|
||||
entries: &[horizon_shader_api::manifest::ParticleLight],
|
||||
partial: f32,
|
||||
) -> Vec<([f64; 3], u32, f32, f32)> {
|
||||
let shining: Vec<(ParticleType, u32, f32)> = entries
|
||||
.iter()
|
||||
.flat_map(|e| {
|
||||
let [r, g, b] = e.color.map(|c| (c.clamp(0.0, 1.0) * 255.0) as u32);
|
||||
e.particles.iter().filter_map(move |name| {
|
||||
let name = if name.contains(':') {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
};
|
||||
Some((
|
||||
ParticleType::from_name(&name)?,
|
||||
r << 16 | g << 8 | b,
|
||||
e.reach,
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if shining.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let f = f64::from(partial);
|
||||
self.particles
|
||||
.iter()
|
||||
.filter(|p| p.rgba[3] > 0.0)
|
||||
.filter_map(|p| {
|
||||
let (_, rgb, reach) = shining.iter().find(|(ty, ..)| *ty == p.ty)?;
|
||||
let at =
|
||||
std::array::from_fn(|i| p.previous[i] + (p.position[i] - p.previous[i]) * f);
|
||||
Some((at, *rgb, *reach, p.rgba[3].clamp(0.0, 1.0)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Camera-facing quads (`QuadParticleRenderState.renderRotatedQuad`).
|
||||
pub fn render(
|
||||
&self,
|
||||
|
||||
@@ -133,6 +133,21 @@ pub fn text(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow:
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `graphics.text` on text the caller already styled, drawn as it is: a `§`
|
||||
/// in it stays a character, as `FormattedCharSequence.forward`
|
||||
/// (`StringDecomposer.iterate`, not `iterateFormatted`) draws it. An edit box
|
||||
/// builds its own sequence that way, so a `§` in its value shows.
|
||||
pub fn text_as_typed(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow: bool) {
|
||||
if (color as u32) >> 24 != 0 {
|
||||
out.push(Draw::Text {
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
shadow,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `StringDecomposer.iterateFormatted`: the drawn characters of `contents`,
|
||||
/// with their index in it and their style; legacy `§` codes restyle what
|
||||
@@ -186,6 +201,11 @@ impl Measure<'_> {
|
||||
pub fn width(&self, t: &Text) -> i32 {
|
||||
self.0.width_text(&t.resolve_legacy()) as i32
|
||||
}
|
||||
/// `Font.width(FormattedCharSequence)` of a sequence drawn as it is: a
|
||||
/// `§` in the text counts as the glyph it is drawn as.
|
||||
pub fn width_drawn(&self, t: &Text) -> i32 {
|
||||
self.0.width_drawn(t) as i32
|
||||
}
|
||||
pub fn width_str(&self, s: &str) -> i32 {
|
||||
self.0.width(s) as i32
|
||||
}
|
||||
@@ -864,8 +884,8 @@ impl TextField {
|
||||
displayed.clone()
|
||||
};
|
||||
let t = styled(half, self.display);
|
||||
let w = m.width(&t);
|
||||
text(out, t, draw_x, ty, color, self.text_shadow);
|
||||
let w = m.width_drawn(&t);
|
||||
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
|
||||
draw_x += w + 1;
|
||||
}
|
||||
let insert = self.cursor < len || len >= self.max_length;
|
||||
@@ -878,7 +898,7 @@ impl TextField {
|
||||
}
|
||||
if !displayed.is_empty() && on_screen && (rel_cursor as usize) < shown_len {
|
||||
let t = styled(sub(&displayed, rel_cursor as usize, shown_len), self.cursor);
|
||||
text(out, t, draw_x, ty, color, self.text_shadow);
|
||||
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
|
||||
}
|
||||
if let Some(hint) = &self.hint
|
||||
&& displayed.is_empty()
|
||||
|
||||
@@ -46,6 +46,30 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
out
|
||||
}
|
||||
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
|
||||
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
|
||||
pub struct Laps(Option<(Instant, Option<Duration>)>);
|
||||
/// Start timing the steps of a function (see [`Laps`]).
|
||||
#[inline]
|
||||
pub fn laps() -> Laps {
|
||||
Laps(enabled().then(|| (Instant::now(), thread_cpu())))
|
||||
}
|
||||
impl Laps {
|
||||
/// Record the step that just ended under `name`, and start the next.
|
||||
#[inline]
|
||||
pub fn lap(&mut self, name: &'static str) {
|
||||
let Some((started, cpu)) = &mut self.0 else {
|
||||
return;
|
||||
};
|
||||
let (now, now_cpu) = (Instant::now(), thread_cpu());
|
||||
let ms = (now - *started).as_secs_f32() * 1000.0;
|
||||
let cpu_ms = now_cpu
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
}
|
||||
}
|
||||
/// The CPU time this thread has used, where the platform tells it: a tick
|
||||
/// with more wall time than CPU time waited (for a lock, or for a core on a
|
||||
/// busy machine).
|
||||
@@ -293,6 +317,25 @@ mod tests {
|
||||
assert!(super::take().is_empty());
|
||||
}
|
||||
|
||||
/// Laps record the steps between them while spans are on, and nothing while off.
|
||||
#[test]
|
||||
fn laps_time_consecutive_steps() {
|
||||
let _ = super::take();
|
||||
let mut off = super::laps();
|
||||
off.lap("unseen");
|
||||
super::enable(true);
|
||||
let mut laps = super::laps();
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
laps.lap("first");
|
||||
laps.lap("second");
|
||||
super::enable(false);
|
||||
off.lap("still unseen");
|
||||
let spans = super::take();
|
||||
let names: Vec<_> = spans.iter().map(|s| s.name).collect();
|
||||
assert_eq!(names, ["first", "second"]);
|
||||
assert!(spans[0].ms >= 2.0);
|
||||
}
|
||||
|
||||
/// The thread's CPU clock moves with work done on it, not with time
|
||||
/// spent asleep.
|
||||
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
|
||||
|
||||
@@ -413,6 +413,119 @@ impl Sprites {
|
||||
};
|
||||
self.register(root, resource, decoded)
|
||||
}
|
||||
/// The tiles of the texture `name` (`ns:path`) cut into a `columns` x `rows` grid, row by
|
||||
/// row from the top left: a Fusion connecting texture's sheet. Each tile is its own layer
|
||||
/// (named `fusion/<ns>/<path>#<tile>`); a fully transparent one is `None`, as Fusion skips
|
||||
/// empty tiles. Tiles are square, as wide as a column. An animated sheet's frames are whole
|
||||
/// grids one below the other, or with `per_tile` each cell is a column of its tile's frames.
|
||||
/// The sheet's own layer, if models already show it, shows its first tile from then on.
|
||||
pub fn insert_tiles(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
name: &str,
|
||||
(columns, rows): (u32, u32),
|
||||
per_tile: bool,
|
||||
) -> Result<Vec<Option<u32>>> {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
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>>,
|
||||
>(&text)?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let animation = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("animation"))
|
||||
.map(|v| v.get().to_owned());
|
||||
// The sheet's `texture` section is each tile's.
|
||||
let texture = texture_section(metadata.as_ref());
|
||||
let strategy = texture
|
||||
.as_ref()
|
||||
.and_then(|t| t["mipmap_strategy"].as_str())
|
||||
.and_then(crate::mipmap::Strategy::parse)
|
||||
.unwrap_or_default();
|
||||
let alpha_cutoff_bias = texture
|
||||
.as_ref()
|
||||
.and_then(|t| t["alpha_cutoff_bias"].as_f64())
|
||||
.unwrap_or(0.0) as f32;
|
||||
let (width, height, pixels) = read_rgba(&path)?;
|
||||
let side = width / columns.max(1);
|
||||
// How far down the next frame of a tile is, and the next row of tiles.
|
||||
let (frame_step, row_step) = match per_tile {
|
||||
true => (side, height / rows.max(1)),
|
||||
false => (rows * side, side),
|
||||
};
|
||||
let frames = match per_tile {
|
||||
true => row_step / side.max(1),
|
||||
false => height / frame_step.max(1),
|
||||
};
|
||||
let whole = match per_tile {
|
||||
true => height % rows.max(1) == 0 && row_step % side.max(1) == 0,
|
||||
false => height % frame_step.max(1) == 0,
|
||||
};
|
||||
if side == 0 || frames == 0 || width % columns.max(1) != 0 || !whole {
|
||||
return Err(format!("{name} isn't a {columns} x {rows} grid of square tiles").into());
|
||||
}
|
||||
let tiles = (0..columns * rows)
|
||||
.map(|tile| {
|
||||
let frames: Vec<Vec<u8>> = (0..frames)
|
||||
.map(|f| {
|
||||
let (x, y) = (
|
||||
tile % columns * side,
|
||||
tile / columns * row_step + f * frame_step,
|
||||
);
|
||||
(y..y + side)
|
||||
.flat_map(|row| {
|
||||
let start = ((row * width + x) * 4) as usize;
|
||||
pixels[start..start + side as usize * 4].iter().copied()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let alphas = || frames.iter().flat_map(|f| f.iter().skip(3).step_by(4));
|
||||
if alphas().all(|&a| a == 0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let decoded = crate::sprite_cache::Decoded {
|
||||
side,
|
||||
animation: animation.clone().filter(|_| frames.len() > 1),
|
||||
strategy,
|
||||
alpha_cutoff_bias,
|
||||
has_transparent: alphas().any(|&a| a == 0),
|
||||
opaque: alphas().all(|&a| a == 255),
|
||||
translucent: alphas().any(|&a| a != 0 && a != 255),
|
||||
frames,
|
||||
};
|
||||
let tile_name = format!("fusion/{}#{tile}", name.replace(':', "/"));
|
||||
self.register(root, &tile_name, decoded).map(Some)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
// Where Fusion doesn't draw the sheet (items, other models), the lone tile rather than
|
||||
// the whole sheet squeezed square, whose size would set every layer's.
|
||||
if let (Some(&own), Some(&first)) = (self.layers.get(name), tiles.iter().flatten().next()) {
|
||||
self.show_as(own, first);
|
||||
}
|
||||
Ok(tiles)
|
||||
}
|
||||
/// Layer `layer` shows what `source` does, and is no longer the layer of its old pixels.
|
||||
fn show_as(&mut self, layer: u32, source: u32) {
|
||||
self.still.retain(|_, l| *l != layer);
|
||||
let (l, s) = (layer as usize, source as usize);
|
||||
self.native[l] = self.native[s].clone();
|
||||
self.opaque[l] = self.opaque[s];
|
||||
self.translucent[l] = self.translucent[s];
|
||||
self.mips[l] = self.mips[s];
|
||||
self.pbr[l] = self.pbr[s].clone();
|
||||
self.animations.retain(|a| a.layer != layer);
|
||||
if let Some(animation) = self.animations.iter().find(|a| a.layer == source) {
|
||||
let animation = SpriteAnimation {
|
||||
layer,
|
||||
..animation.clone()
|
||||
};
|
||||
self.animations.push(animation);
|
||||
}
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
if !name.contains(':') {
|
||||
@@ -762,6 +875,75 @@ mod tests {
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn tile_sheets_cut_into_a_layer_per_tile() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-tiles-{}", std::process::id()));
|
||||
let file = root.join("assets/minecraft/textures/block/sheet.png");
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
// 3 columns of 2 x 2 tiles over 4 rows of pixels: the pixel at (x, y) has red x, green
|
||||
// y; the middle column is transparent, and the top-right tile half see-through.
|
||||
let (w, h) = (6u32, 4u32);
|
||||
let pixels: Vec<u8> = (0..w * h)
|
||||
.flat_map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
let alpha = match x / 2 {
|
||||
1 => 0,
|
||||
2 if y < 2 => 128,
|
||||
_ => 255,
|
||||
};
|
||||
[x as u8, y as u8, 0, alpha]
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = png::Encoder::new(File::create(&file).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
// Red and green of each pixel in a layer's frame.
|
||||
let texels = |sprites: &Sprites, layer: u32, frame: usize| -> Vec<(u8, u8)> {
|
||||
sprites.native[layer as usize].1[frame]
|
||||
.chunks(4)
|
||||
.map(|p| (p[0], p[1]))
|
||||
.collect()
|
||||
};
|
||||
let mut sprites = Sprites::default();
|
||||
let name = "minecraft:block/sheet";
|
||||
let own = sprites.insert(&root, name).unwrap();
|
||||
// Still, as a 3 x 2 grid: the empty middle column draws nothing.
|
||||
let tiles = sprites.insert_tiles(&root, name, (3, 2), false).unwrap();
|
||||
// The sheet's own layer now shows the top-left tile, at a tile's size.
|
||||
assert_eq!(texels(&sprites, own, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
|
||||
assert_eq!(
|
||||
tiles.iter().map(Option::is_some).collect::<Vec<_>>(),
|
||||
[true, false, true, true, false, true]
|
||||
);
|
||||
let bottom_left = tiles[3].unwrap();
|
||||
assert_eq!(
|
||||
texels(&sprites, bottom_left, 0),
|
||||
[(0, 2), (1, 2), (0, 3), (1, 3)]
|
||||
);
|
||||
assert!(sprites.translucent[tiles[2].unwrap() as usize]);
|
||||
assert!(sprites.opaque[bottom_left as usize]);
|
||||
// Animated, as one row of tiles: two frames, the grid's top and then its bottom half;
|
||||
// per tile, each cell is the column of a tile's frames, which here is the same.
|
||||
fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
|
||||
for per_tile in [false, true] {
|
||||
let mut sprites = Sprites::default();
|
||||
let tiles = sprites.insert_tiles(&root, name, (3, 1), per_tile).unwrap();
|
||||
let first = tiles[0].unwrap();
|
||||
assert_eq!(texels(&sprites, first, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
|
||||
assert_eq!(texels(&sprites, first, 1), [(0, 2), (1, 2), (0, 3), (1, 3)]);
|
||||
assert_eq!(sprites.animations.len(), 2, "two non-empty tiles");
|
||||
}
|
||||
// Not a grid of square tiles.
|
||||
assert!(sprites.insert_tiles(&root, name, (4, 1), false).is_err());
|
||||
assert!(sprites.insert_tiles(&root, name, (3, 3), false).is_err());
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn custom_item_texture_ids_load_with_their_animation() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sprite-cit-{}", std::process::id()));
|
||||
let file = root.join("assets/minecraft/optifine/cit/a.png");
|
||||
|
||||
@@ -37,6 +37,9 @@ struct Globals {
|
||||
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
|
||||
// [0] where the lift starts, [1] where it is full.
|
||||
hdr_windows: array<vec4<f32>, 2>,
|
||||
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
|
||||
// y what the sky and fog get: the same, but none under water.
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -171,6 +174,8 @@ struct TerrainOut {
|
||||
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
|
||||
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
|
||||
@location(5) @interpolate(flat) emission: u32,
|
||||
// HDR lighting: block and sky light, as levels 0 to 15.
|
||||
@location(11) scene: vec2<f32>,
|
||||
@location(6) @interpolate(flat) fade: u32,
|
||||
// A distant tile's tile-relative position, for its fade (`faded`).
|
||||
@location(7) local: vec3<f32>,
|
||||
@@ -361,6 +366,22 @@ fn left_out() -> TerrainOut {
|
||||
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
|
||||
origin.w);
|
||||
}
|
||||
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
|
||||
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
|
||||
fn hdr_scene(block: f32, sky: f32, facing: f32) -> f32 {
|
||||
let b = smoothstep(11.0, 15.0, block);
|
||||
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x * facing;
|
||||
return max(b, s);
|
||||
}
|
||||
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
|
||||
fn hdr_scene_alpha(scene: f32) -> f32 {
|
||||
return 1.0 - 0.24 * scene;
|
||||
}
|
||||
// The face's normal, from the fragment's derivatives, for how it faces the sun.
|
||||
var<private> hdr_normal: vec3<f32>;
|
||||
fn hdr_facing() -> f32 {
|
||||
return mix(0.45, 1.0, max(dot(hdr_normal, globals.sun.xyz), 0.0));
|
||||
}
|
||||
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
|
||||
let pos = position + origin;
|
||||
@@ -384,6 +405,7 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
|
||||
}
|
||||
let block = max(f32(light.y), dynamic_block_light(pos));
|
||||
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
|
||||
out.scene = vec2(block / 16.0, f32(light.x) / 16.0);
|
||||
if globals.colored_count.x > 0u {
|
||||
out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
|
||||
}
|
||||
@@ -426,6 +448,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
}
|
||||
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
|
||||
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
|
||||
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
var color = texel * in.color;
|
||||
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
|
||||
@@ -440,6 +463,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
|
||||
fn solid_color(in: TerrainOut) -> vec4<f32> {
|
||||
let dx = dpdx(in.local);
|
||||
let dy = dpdy(in.local);
|
||||
hdr_normal = normalize(cross(dy, dx));
|
||||
if faded(in.fade, in.local, dx, dy) { discard; }
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
let texel = sprite(in.uv, in.layer & 0x7fffu);
|
||||
@@ -456,15 +480,24 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
|
||||
// texel's own brightness, so its light level and face shading don't change
|
||||
// it. Only pixels as bright as their sprite's threshold may: its bright
|
||||
// parts, or all of it. Nothing else reads the frame's alpha.
|
||||
var emissive = 1.0;
|
||||
// Fog turns a far surface into the fog's colour: it takes the fog's light with it, or a
|
||||
// fully fogged mountain would stand out from the fog behind it.
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
var emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
|
||||
globals.hdr_light.y, fogged));
|
||||
if in.emission > 0u {
|
||||
// A light source's own face shines as one or stays as drawn, without the lighting.
|
||||
emissive = 1.0;
|
||||
let whole = in.emission > 4u;
|
||||
let tier = select(in.emission, in.emission - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
|
||||
let y = brightness(texel.rgb);
|
||||
if y >= select(t.r, t.g, whole) {
|
||||
emissive = hdr_mark(tier, y);
|
||||
// All over means all over: the sprite's range is one frame's, and an animated sprite's
|
||||
// other frames dip below it. Fog hides a light source as it hides everything else.
|
||||
if whole || y >= t.r {
|
||||
emissive = hdr_mark(tier, y * (1.0 - fogged));
|
||||
}
|
||||
}
|
||||
if in.emissive_layer != 0xffffffffu {
|
||||
@@ -612,7 +645,8 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
|
||||
return color;
|
||||
}
|
||||
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
|
||||
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -384,19 +384,42 @@ pub fn scrolling_text_at(
|
||||
y: f32,
|
||||
now_ms: i64,
|
||||
color: u32,
|
||||
) {
|
||||
// `acceptScrollingWithDefaultCenter`: `centerX = (left + right) / 2`.
|
||||
let center_x = left + ((right - left) / 2.0).floor();
|
||||
scrolling_text_around_at(
|
||||
batch, atlas, text, center_x, left, right, top, bottom, y, now_ms, color,
|
||||
);
|
||||
}
|
||||
|
||||
/// [`scrolling_text_at`] around an explicit `center_x`
|
||||
/// (`ActiveTextCollector.acceptScrolling`, which `SpriteIconButton.TextAndIcon`
|
||||
/// calls with the button's middle): a label that fits is centred on `center_x`
|
||||
/// moved as far as it must to stay between `left` and `right`
|
||||
/// (`Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2)`), so
|
||||
/// beside an icon it ends where the icon's room begins. A wider one is drawn
|
||||
/// from the left and slid inside the box.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn scrolling_text_around_at(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
text: &Text,
|
||||
center_x: f32,
|
||||
left: f32,
|
||||
right: f32,
|
||||
top: f32,
|
||||
bottom: f32,
|
||||
y: f32,
|
||||
now_ms: i64,
|
||||
color: u32,
|
||||
) {
|
||||
let text = text.resolved();
|
||||
let width = atlas.font.width_text(&text);
|
||||
let room = right - left;
|
||||
if width <= room {
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&text,
|
||||
centered_left(left, room, width),
|
||||
y,
|
||||
color,
|
||||
true,
|
||||
);
|
||||
let half = (width / 2.0).floor();
|
||||
let text_x = center_x.max(left + half).min(right - half);
|
||||
batch.text_styled(atlas, &text, text_x - half, y, color, true);
|
||||
return;
|
||||
}
|
||||
// Scissored to the widget: what does not fit is read as the label
|
||||
@@ -440,6 +463,37 @@ pub fn scrolling_text(
|
||||
);
|
||||
}
|
||||
|
||||
/// `MenuTabBar.MenuTabButton.renderLabel` for the tab at `x, y`: its label
|
||||
/// inside the tab less 1 px on each side, from the tab's top when selected
|
||||
/// and 3 px lower when not (`acceptScrollingWithDefaultCenter`, so one wider
|
||||
/// than the tab slides instead of running into the next tab). White, gray
|
||||
/// for an inactive tab (`WithInactiveMessage`).
|
||||
pub fn menu_tab_label(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
label: &Text,
|
||||
(x, y, width, height): (f32, f32, f32, f32),
|
||||
selected: bool,
|
||||
active: bool,
|
||||
now_ms: i64,
|
||||
) {
|
||||
let top = y + if selected { 0.0 } else { 3.0 };
|
||||
let bottom = y + height;
|
||||
scrolling_text_at(
|
||||
batch,
|
||||
atlas,
|
||||
label,
|
||||
x + 1.0,
|
||||
x + width - 1.0,
|
||||
top,
|
||||
bottom,
|
||||
// defaultScrollingHelper: `(top + bottom - lineHeight) / 2 + 1`.
|
||||
((top + bottom - 9.0) / 2.0).floor() + 1.0,
|
||||
now_ms,
|
||||
if active { u32::MAX } else { 0xFFA0_A0A0 },
|
||||
);
|
||||
}
|
||||
|
||||
/// Button (AbstractButton): nine-slice sprite plus the centred label.
|
||||
pub fn button(
|
||||
batch: &mut HudBatch,
|
||||
@@ -1642,6 +1696,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `MenuTabButton.renderLabel` on a 24 px tab: centred on the tab, its
|
||||
/// text top at `(0 + 24 - 9) / 2 + 1 = 8` when selected and at
|
||||
/// `(3 + 24 - 9) / 2 + 1 = 10` when not; white on an active tab, selected
|
||||
/// or not, `0xA0A0A0` on an inactive one (`WithInactiveMessage`). The
|
||||
/// Statistics tabs drew every label at 8 and an active unselected one gray.
|
||||
#[test]
|
||||
fn a_tab_label_sits_lower_on_an_unselected_tab_and_is_gray_only_when_inactive() {
|
||||
let atlas = atlas();
|
||||
let label = Text::literal("Items");
|
||||
let half = (atlas.font.width_text(&label.resolved()) / 2.0).floor();
|
||||
// A 124 px tab at x = 100: `(left + right) / 2 = (101 + 223) / 2 = 162`.
|
||||
let left = 162.0 - half;
|
||||
for (selected, active, y, color) in [
|
||||
(true, true, 8.0, u32::MAX),
|
||||
(false, true, 10.0, u32::MAX),
|
||||
(false, false, 10.0, 0xFFA0_A0A0),
|
||||
] {
|
||||
let mut tab = HudBatch::new([640, 480]);
|
||||
menu_tab_label(
|
||||
&mut tab,
|
||||
&atlas,
|
||||
&label,
|
||||
(100.0, 0.0, 124.0, 24.0),
|
||||
selected,
|
||||
active,
|
||||
0,
|
||||
);
|
||||
let mut want = HudBatch::new([640, 480]);
|
||||
want.text_styled(&atlas, &label.resolved(), left, y, color, true);
|
||||
let glyphs = |b: &HudBatch| -> Vec<([u32; 2], u32)> {
|
||||
b.vertices
|
||||
.iter()
|
||||
.filter(|v| v.text == 1)
|
||||
.map(|v| ([v.pos[0].to_bits(), v.pos[1].to_bits()], v.color))
|
||||
.collect()
|
||||
};
|
||||
assert!(!glyphs(&want).is_empty());
|
||||
assert_eq!(
|
||||
glyphs(&tab),
|
||||
glyphs(&want),
|
||||
"selected {selected}, active {active}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_track_is_outlined_only_while_dragged() {
|
||||
let (track, handle) = slider_sprites(true, true, false);
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "GUI-Helligkeit",
|
||||
"Battery Mode": "Akkumodus",
|
||||
"Graphics API": "Grafik-API",
|
||||
"Translucent Batching": "Transparenz-Batching",
|
||||
"Performance Overlay": "Leistungsanzeige",
|
||||
"Full Detail Distance": "Detailweite",
|
||||
"On": "An",
|
||||
@@ -142,6 +143,7 @@
|
||||
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
|
||||
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
|
||||
"Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.",
|
||||
"%s chunks": "%s Chunks",
|
||||
"%s per frame": "%s pro Bild",
|
||||
"%s MiB/frame": "%s MiB/Bild",
|
||||
@@ -164,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
|
||||
@@ -198,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Wasser und Glas in einem Puffer: weniger Draw-Calls, etwas mehr VRAM. Nach Neustart.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
@@ -286,5 +288,53 @@
|
||||
"horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.",
|
||||
"horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
|
||||
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …"
|
||||
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteckt Modelle hinter Blöcken. Prüfung im Hintergrund; Objekte laufen weiter.",
|
||||
"Leaf Culling": "Laub-Culling",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Jede Blattfläche, wie Minecraft sie bei schönen Blättern zeichnet.",
|
||||
"Distant Plant Dither": "Pflanzen-Dithering",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Verhindert, dass feine Pflanzen in hochauflösenden Paketen in der Ferne verblassen.",
|
||||
"Screenshot Quality": "Screenshot-Qualität",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2-Screenshots in diesem Vielfachen der Spielauflösung. Höher dauert länger.",
|
||||
"Frame Generation": "Bildgenerierung",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Zeigt zwischen zwei echten Bildern ein errechnetes: flüssiger, etwas mehr Verzögerung.",
|
||||
"Shadow Cache": "Schatten-Cache",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Verwendet die Schattenkarte des Pakets erneut, wenn Sonne, Blick und Welt gleich bleiben. Standardmäßig aus.",
|
||||
"Synchronous": "Synchron",
|
||||
"Asynchronous": "Asynchron",
|
||||
"Solid": "Massiv",
|
||||
"Solid Aggressive": "Massiv, aggressiv",
|
||||
"Hollow": "Hohl",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Blätter mitten im Blätterdach werden massiv gezeichnet: weniger durchsichtige Schichten.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Auch Blätter mit einer offenen Seite werden massiv gezeichnet: schneller, gröbere Kanten.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
|
||||
"Hotbar: Previous Slot": "Schnellzugriffsleiste: Vorheriger Platz",
|
||||
"Hotbar: Next Slot": "Schnellzugriffsleiste: Nächster Platz",
|
||||
"horizon.agent.connected.one": "Verbunden: 1 Agentenverbindung",
|
||||
"horizon.agent.connected.many": "Verbunden: %s Agentenverbindungen",
|
||||
"horizon.agent.connected.one_ago": "Verbunden: 1 Agentenverbindung, letzter Befehl vor %s s",
|
||||
"horizon.agent.connected.many_ago": "Verbunden: %s Agentenverbindungen, letzter Befehl vor %s s",
|
||||
"horizon.agent.waiting": "Wartet auf einen Agenten.",
|
||||
"horizon.agent.off": "Aus: Agenten können dieses Spiel nicht erreichen.",
|
||||
"horizon.agent.session_opened": "Für diese Sitzung von dem Programm geöffnet, das das Spiel gestartet hat.",
|
||||
"horizon.agent.socket": "Socket: %s (Verbindungsdaten kopieren für den vollständigen Pfad)",
|
||||
"horizon.agent.local_only": "Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
|
||||
"horizon.extras.chat_animation": "Weicher Chat",
|
||||
"horizon.extras.chat_animation.tooltip": "Eine neue Chatzeile gleitet von links herein und blendet dabei ein, statt sofort ganz zu erscheinen.",
|
||||
"horizon.extras.chat_indicator": "Nachrichtenmarkierung",
|
||||
"horizon.extras.chat_indicator.tooltip": "Der farbige Balken links neben einer Chatzeile.",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.particles": "Partikel",
|
||||
"horizon.extras.header.sound": "Ton",
|
||||
"horizon.extras.hide_block_particles": "Blockpartikel weg",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Blendet die Trümmerpartikel aus, die aus einem zerstörten Block sprühen und von dem abbröckeln, den du abbaust. Andere Partikel bleiben.",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.splitscreen.tooltip": "In einer Welt kann ein weiterer Controller als neuer Spieler beitreten: A drücken, wenn gefragt, dann Profil und Spielort wählen.",
|
||||
"horizon.extras.ui_sound_reserve": "Kanäle für UI-Töne",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Weltgeräusche lassen 16 der 247 Tonkanäle frei, damit eine Flut davon (eine große Explosion) Menü-Klicks nicht verstummen lässt.",
|
||||
"horizon.pages.open.split_setup": "Splitscreen …",
|
||||
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
|
||||
"horizon.pages.split_setup": "Splitscreen",
|
||||
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten."
|
||||
}
|
||||
@@ -170,7 +170,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
|
||||
@@ -355,5 +354,36 @@
|
||||
"horizon.splitscreen.setup.help.names": "Each player's name in the corner of their view.",
|
||||
"horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.",
|
||||
"horizon.splitscreen.setup.help.apply": "Play the way the map shows.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet."
|
||||
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
|
||||
"Culling": "Culling",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Leaf Culling": "Leaf Culling",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
|
||||
"Distant Plant Dither": "Distant Plant Dither",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Keeps fine plants in high-res packs from fading far away.",
|
||||
"Screenshot Quality": "Screenshot Quality",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 screenshots at this many times the game's resolution. Higher takes longer.",
|
||||
"Frame Generation": "Frame Generation",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.",
|
||||
"Shadow Cache": "Shadow Cache",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.",
|
||||
"Synchronous": "Synchronous",
|
||||
"Asynchronous": "Asynchronous",
|
||||
"Solid": "Solid",
|
||||
"Solid Aggressive": "Solid Aggressive",
|
||||
"Hollow": "Hollow",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Leaves fully inside a canopy draw solid: fewer see-through layers.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Leaves with one open side draw solid too: faster, chunkier edges.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Drops faces between leaves: faster, but gaps look through.",
|
||||
"Hotbar: Previous Slot": "Hotbar: Previous Slot",
|
||||
"Hotbar: Next Slot": "Hotbar: Next Slot",
|
||||
"horizon.agent.connected.one": "Connected: 1 agent connection",
|
||||
"horizon.agent.connected.many": "Connected: %s agent connections",
|
||||
"horizon.agent.connected.one_ago": "Connected: 1 agent connection, last command %s s ago",
|
||||
"horizon.agent.connected.many_ago": "Connected: %s agent connections, last command %s s ago",
|
||||
"horizon.agent.waiting": "Waiting for an agent.",
|
||||
"horizon.agent.off": "Off: agents can't reach this game.",
|
||||
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
|
||||
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
|
||||
"horizon.agent.local_only": "Local only: agents on this computer, never the network."
|
||||
}
|
||||
@@ -158,7 +158,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "Luminosité UI",
|
||||
"Battery Mode": "Mode batterie",
|
||||
"Graphics API": "API graphique",
|
||||
"Translucent Batching": "Regroupement translucide",
|
||||
"Performance Overlay": "Infos de perfs",
|
||||
"Full Detail Distance": "Dist. détail",
|
||||
"On": "Oui",
|
||||
@@ -142,6 +143,7 @@
|
||||
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
|
||||
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
|
||||
"Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s par image",
|
||||
"%s MiB/frame": "%s MiB/image",
|
||||
@@ -164,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
|
||||
@@ -198,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Eau et verre dans un seul tampon : moins d'appels de dessin, un peu plus de VRAM. Au redémarrage.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
@@ -340,5 +342,48 @@
|
||||
"horizon.splitscreen.setup.help.names": "Le nom de chaque joueur dans le coin de sa vue.",
|
||||
"horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.",
|
||||
"horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.",
|
||||
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé."
|
||||
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.",
|
||||
"Culling": "Occlusion",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
|
||||
"Leaf Culling": "Occlusion feuillage",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Toutes les faces de feuillage, comme les feuilles détaillées de Minecraft.",
|
||||
"Distant Plant Dither": "Tramage des plantes",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Empêche les plantes fines des packs haute résolution de s'estomper au loin.",
|
||||
"Screenshot Quality": "Qualité des captures",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Captures F2 à ce multiple de la résolution du jeu. Plus c'est haut, plus c'est long.",
|
||||
"Frame Generation": "Génération d'images",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.",
|
||||
"Shadow Cache": "Cache des ombres",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.",
|
||||
"Synchronous": "Synchrone",
|
||||
"Asynchronous": "Asynchrone",
|
||||
"Solid": "Opaque",
|
||||
"Solid Aggressive": "Opaque agressif",
|
||||
"Hollow": "Creux",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Les feuilles au cœur d'un feuillage sont opaques : moins de couches transparentes.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Les feuilles avec un côté ouvert sont aussi opaques : plus rapide, bords plus grossiers.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
|
||||
"Hotbar: Previous Slot": "Barre d'action : case précédente",
|
||||
"Hotbar: Next Slot": "Barre d'action : case suivante",
|
||||
"horizon.agent.connected.one": "Connecté : 1 connexion d'agent",
|
||||
"horizon.agent.connected.many": "Connecté : %s connexions d'agent",
|
||||
"horizon.agent.connected.one_ago": "Connecté : 1 connexion d'agent, dernière commande il y a %s s",
|
||||
"horizon.agent.connected.many_ago": "Connecté : %s connexions d'agent, dernière commande il y a %s s",
|
||||
"horizon.agent.waiting": "En attente d'un agent.",
|
||||
"horizon.agent.off": "Désactivé : les agents ne peuvent pas joindre ce jeu.",
|
||||
"horizon.agent.session_opened": "Ouvert pour cette session par le programme qui a lancé le jeu.",
|
||||
"horizon.agent.socket": "Socket : %s (copiez les détails pour le chemin complet)",
|
||||
"horizon.agent.local_only": "Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
|
||||
"horizon.extras.chat_animation": "Chat fluide",
|
||||
"horizon.extras.chat_animation.tooltip": "Une nouvelle ligne de chat glisse depuis la gauche en apparaissant progressivement, au lieu d'apparaître d'un coup.",
|
||||
"horizon.extras.chat_indicator": "Marqueur de message",
|
||||
"horizon.extras.chat_indicator.tooltip": "La barre colorée à gauche d'une ligne de chat.",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.particles": "Particules",
|
||||
"horizon.extras.header.sound": "Son",
|
||||
"horizon.extras.hide_block_particles": "Masquer particules",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
|
||||
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
|
||||
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu."
|
||||
}
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "Jasność interfejsu",
|
||||
"Battery Mode": "Tryb baterii",
|
||||
"Graphics API": "API grafiki",
|
||||
"Translucent Batching": "Grupowanie półprzezroczystych",
|
||||
"Performance Overlay": "Nakładka wydajności",
|
||||
"Full Detail Distance": "Zasięg detali",
|
||||
"On": "Wł.",
|
||||
@@ -142,6 +143,7 @@
|
||||
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
|
||||
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
|
||||
"Your GPU or graphics API doesn't support it.": "Twoje GPU lub API graficzne tego nie obsługuje.",
|
||||
"%s chunks": "%s chunków",
|
||||
"%s per frame": "%s na klatkę",
|
||||
"%s MiB/frame": "%s MiB/klatka",
|
||||
@@ -164,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
|
||||
@@ -198,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Woda i szkło w jednym buforze: mniej wywołań rysowania, trochę więcej VRAM. Po restarcie.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
@@ -286,5 +288,53 @@
|
||||
"horizon.osk.delete": "Usuń",
|
||||
"horizon.osk.type": "Wpisz",
|
||||
"horizon.osk.symbols": "Symbole",
|
||||
"horizon.osk.close": "Zamknij"
|
||||
"horizon.osk.close": "Zamknij",
|
||||
"Culling": "Pomijanie",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ukrywa modele za pełnymi blokami. Sprawdzanie w tle; byty nadal się aktualizują.",
|
||||
"Leaf Culling": "Pomijanie liści",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Każda ściana liści, tak jak rysuje je Minecraft.",
|
||||
"Distant Plant Dither": "Dithering roślin",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Zapobiega zanikaniu drobnych roślin w paczkach wysokiej rozdzielczości w oddali.",
|
||||
"Screenshot Quality": "Jakość zrzutów ekranu",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Zrzuty F2 w tylokrotności rozdzielczości gry. Więcej trwa dłużej.",
|
||||
"Frame Generation": "Generowanie klatek",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Pokazuje wygenerowaną klatkę między każdymi dwiema prawdziwymi: płynniej, trochę większe opóźnienie.",
|
||||
"Shadow Cache": "Pamięć cieni",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Używa ponownie mapy cieni paczki, gdy słońce, widok i świat się zgadzają. Domyślnie wyłączone.",
|
||||
"Synchronous": "Synchroniczne",
|
||||
"Asynchronous": "Asynchroniczne",
|
||||
"Solid": "Pełne",
|
||||
"Solid Aggressive": "Pełne, agresywnie",
|
||||
"Hollow": "Puste",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Liście w środku korony rysują się jako pełne: mniej przezroczystych warstw.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Liście z jedną otwartą stroną też rysują się jako pełne: szybciej, grubsze krawędzie.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
|
||||
"Hotbar: Previous Slot": "Pasek szybkiego wyboru: poprzednie pole",
|
||||
"Hotbar: Next Slot": "Pasek szybkiego wyboru: następne pole",
|
||||
"horizon.agent.connected.one": "Połączono: 1 połączenie agenta",
|
||||
"horizon.agent.connected.many": "Połączenia agenta: %s",
|
||||
"horizon.agent.connected.one_ago": "Połączono: 1 połączenie agenta, ostatnie polecenie %s s temu",
|
||||
"horizon.agent.connected.many_ago": "Połączenia agenta: %s, ostatnie polecenie %s s temu",
|
||||
"horizon.agent.waiting": "Czeka na agenta.",
|
||||
"horizon.agent.off": "Wyłączone: agenci nie mają dostępu do tej gry.",
|
||||
"horizon.agent.session_opened": "Otwarte na tę sesję przez program, który uruchomił grę.",
|
||||
"horizon.agent.socket": "Gniazdo: %s (skopiuj dane, aby poznać pełną ścieżkę)",
|
||||
"horizon.agent.local_only": "Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
|
||||
"horizon.extras.chat_animation": "Płynny czat",
|
||||
"horizon.extras.chat_animation.tooltip": "Nowa linia czatu wsuwa się z lewej i pojawia stopniowo, zamiast wyświetlać się od razu w całości.",
|
||||
"horizon.extras.chat_indicator": "Znacznik wiadomości",
|
||||
"horizon.extras.chat_indicator.tooltip": "Kolorowy pasek po lewej stronie linii czatu.",
|
||||
"horizon.extras.header.chat": "Czat",
|
||||
"horizon.extras.header.particles": "Cząsteczki",
|
||||
"horizon.extras.header.sound": "Dźwięk",
|
||||
"horizon.extras.hide_block_particles": "Ukryj cząstki bloków",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Ukrywa cząsteczki odłamków, które tryskają ze zniszczonego bloku i kruszą się z tego, który kopiesz. Inne cząsteczki zostają.",
|
||||
"horizon.extras.splitscreen": "Podzielony ekran",
|
||||
"horizon.extras.splitscreen.tooltip": "W świecie może dołączyć kolejny kontroler jako nowy gracz: naciśnij A, gdy zapyta, potem wybierz profil i miejsce gry.",
|
||||
"horizon.extras.ui_sound_reserve": "Kanały dla dźwięków UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Dźwięki świata zostawiają wolnych 16 z 247 kanałów, by ich nawał (duży wybuch) nie wyciszył kliknięć w menu.",
|
||||
"horizon.pages.open.split_setup": "Podzielony ekran…",
|
||||
"horizon.pages.split_settings": "Ustawienia podzielonego ekranu",
|
||||
"horizon.pages.split_setup": "Podzielony ekran",
|
||||
"horizon.pages.split_setup.tooltip": "Gracze na tym komputerze: kontrolery mogą dołączyć do twojego świata."
|
||||
}
|
||||
@@ -157,7 +157,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Прячет модели за непрозрачными блоками. Сущности тикают. Выключать со своими наборами.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
|
||||
|
||||
@@ -158,7 +158,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Yaratıkların, ekinlerin ve makinelerin ne kadar uzaktan çalışmaya devam edeceğini belirler. İşlemci gücü kullanır.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Yaratıkların ve eşyaların ne kadar uzaktan görüntüleneceğini, normal mesafeye oranla belirler.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Kapalı alanların arkasında kalan arazilerin çizilmesini önler. Kapalı olması ciddi performans kaybına neden olur.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Opak blokların arkasında kalan modelleri gizler. Varlıkların güncellemeleri devam eder. Özel paketler kullanırken kapatın.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Her karede GPU'ya yüklenen oluşturulmuş bölüm sayısı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Köşelerde yumuşak gölgeler oluşturur. Yalnızca parça oluşturma süresini etkiler.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzak arazilerde blok dokularının daha küçük kopyalarını kullanır. Titreşimli görüntüyü azaltır.",
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"UI Brightness": "Яскравість UI",
|
||||
"Battery Mode": "Режим батареї",
|
||||
"Graphics API": "API графіки",
|
||||
"Translucent Batching": "Групування напівпрозорого",
|
||||
"Performance Overlay": "Накладка швидкодії",
|
||||
"Full Detail Distance": "Дальність деталей",
|
||||
"On": "Увімк.",
|
||||
@@ -142,6 +143,7 @@
|
||||
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
|
||||
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
|
||||
"Your GPU or graphics API doesn't support it.": "Ваш GPU або графічний API цього не підтримує.",
|
||||
"%s chunks": "%s чанків",
|
||||
"%s per frame": "%s на кадр",
|
||||
"%s MiB/frame": "%s MiB/кадр",
|
||||
@@ -164,7 +166,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
|
||||
@@ -198,6 +199,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 у меню/без фокуса), половина потоків: економія.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Потоки генерації, світла й сітки чанків. Після перезапуску.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, інакше DirectX 12 (Windows)/OpenGL (Linux). Після рестарту.",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Вода і скло в одному буфері: менше викликів малювання, трохи більше VRAM. Після перезапуску.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
@@ -286,5 +288,53 @@
|
||||
"horizon.osk.delete": "Видалити",
|
||||
"horizon.osk.type": "Ввести",
|
||||
"horizon.osk.symbols": "Символи",
|
||||
"horizon.osk.close": "Закрити"
|
||||
"horizon.osk.close": "Закрити",
|
||||
"Culling": "Відсікання",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ховає моделі за непрозорими блоками. Перевірка у фоні; сутності оновлюються.",
|
||||
"Leaf Culling": "Відсікання листя",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "Кожна грань листя, як це робить Minecraft у гарній графіці.",
|
||||
"Distant Plant Dither": "Дизеринг рослин",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "Не дає дрібним рослинам у пакетах високої роздільності зникати здалеку.",
|
||||
"Screenshot Quality": "Якість знімків екрана",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Знімки F2 у стільки разів більшій роздільності, ніж гра. Більше значить довше.",
|
||||
"Frame Generation": "Генерація кадрів",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Показує згенерований кадр між кожними двома справжніми: плавніше, трохи більша затримка.",
|
||||
"Shadow Cache": "Кеш тіней",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Повторно використовує карту тіней пакета, коли сонце, вигляд і світ збігаються. Типово вимкнено.",
|
||||
"Synchronous": "Синхронно",
|
||||
"Asynchronous": "Асинхронно",
|
||||
"Solid": "Суцільне",
|
||||
"Solid Aggressive": "Суцільне, агресивно",
|
||||
"Hollow": "Порожнє",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Листя всередині крони малюється суцільним: менше прозорих шарів.",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "Листя з одним відкритим боком теж малюється суцільним: швидше, грубіші краї.",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "Прибирає грані між листям: швидше, але прогалини прозорі.",
|
||||
"Hotbar: Previous Slot": "Панель швидкого доступу: попередній слот",
|
||||
"Hotbar: Next Slot": "Панель швидкого доступу: наступний слот",
|
||||
"horizon.agent.connected.one": "Підключено: 1 з’єднання агента",
|
||||
"horizon.agent.connected.many": "Підключено з’єднань агента: %s",
|
||||
"horizon.agent.connected.one_ago": "Підключено: 1 з’єднання агента, остання команда %s с тому",
|
||||
"horizon.agent.connected.many_ago": "Підключено з’єднань агента: %s, остання команда %s с тому",
|
||||
"horizon.agent.waiting": "Очікування агента.",
|
||||
"horizon.agent.off": "Вимкнено: агенти не можуть дістатися цієї гри.",
|
||||
"horizon.agent.session_opened": "Відкрито на цей сеанс програмою, що запустила гру.",
|
||||
"horizon.agent.socket": "Сокет: %s (скопіюйте дані, щоб побачити повний шлях)",
|
||||
"horizon.agent.local_only": "Лише локально: агенти на цьому комп’ютері, ніколи через мережу.",
|
||||
"horizon.extras.chat_animation": "Плавний чат",
|
||||
"horizon.extras.chat_animation.tooltip": "Новий рядок чату виїжджає зліва, поступово з’являючись, замість того щоб з’явитися одразу.",
|
||||
"horizon.extras.chat_indicator": "Позначка повідомлення",
|
||||
"horizon.extras.chat_indicator.tooltip": "Кольорова смужка ліворуч від рядка чату.",
|
||||
"horizon.extras.header.chat": "Чат",
|
||||
"horizon.extras.header.particles": "Частинки",
|
||||
"horizon.extras.header.sound": "Звук",
|
||||
"horizon.extras.hide_block_particles": "Без частинок блоків",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.",
|
||||
"horizon.extras.splitscreen": "Розділений екран",
|
||||
"horizon.extras.splitscreen.tooltip": "У світі може долучитися ще один геймпад як новий гравець: натисніть A, коли запитає, потім виберіть профіль і де грати.",
|
||||
"horizon.extras.ui_sound_reserve": "Канали для звуків UI",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Звуки у світі залишають вільними 16 із 247 звукових каналів, щоб їхній потік (великий вибух) не заглушив кліки в меню.",
|
||||
"horizon.pages.open.split_setup": "Розділений екран…",
|
||||
"horizon.pages.split_settings": "Налаштування розділеного екрана",
|
||||
"horizon.pages.split_setup": "Розділений екран",
|
||||
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу."
|
||||
}
|
||||
@@ -89,6 +89,7 @@
|
||||
"UI Brightness": "界面亮度",
|
||||
"Battery Mode": "电池模式",
|
||||
"Graphics API": "图形 API",
|
||||
"Translucent Batching": "半透明批处理",
|
||||
"Performance Overlay": "性能叠加层",
|
||||
"Full Detail Distance": "全细节距离",
|
||||
"On": "开",
|
||||
@@ -136,6 +137,7 @@
|
||||
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
|
||||
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
|
||||
"Turn HDR on to use it.": "开启 HDR 后才能使用。",
|
||||
"Your GPU or graphics API doesn't support it.": "你的 GPU 或图形 API 不支持此功能。",
|
||||
"%s chunks": "%s 个区块",
|
||||
"%s per frame": "每帧 %s",
|
||||
"%s MiB/frame": "每帧 %s MiB",
|
||||
@@ -158,7 +160,6 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "隐藏不透明方块后面的模型。实体仍会更新。自定义资源包时应关闭。",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
|
||||
@@ -192,6 +193,7 @@
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "限制 60 FPS(菜单或失焦时 30),工作线程减半:更省电。",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "负责生成、光照与网格化区块的线程数。下次启动生效。",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "优先 Vulkan,失败时用 DirectX 12(Windows)或 OpenGL(Linux)。下次启动生效。",
|
||||
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "水和玻璃放在同一个缓冲区:绘制调用更少,显存略多。下次启动生效。",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
@@ -303,5 +305,36 @@
|
||||
"horizon.osk.close": "关闭",
|
||||
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
|
||||
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
|
||||
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
|
||||
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。",
|
||||
"Culling": "剔除",
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "隐藏不透明方块后面的模型。检测在后台线程进行,实体仍会更新。",
|
||||
"Leaf Culling": "树叶剔除",
|
||||
"Every leaf face. Vanilla's fancy leaves.": "绘制每个树叶面,与原版的华丽树叶一致。",
|
||||
"Distant Plant Dither": "远处植物抖动",
|
||||
"Keeps fine plants in high-res packs from fading far away.": "避免高分辨率资源包中的细小植物在远处逐渐消失。",
|
||||
"Screenshot Quality": "截图质量",
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 截图的分辨率为游戏分辨率的这个倍数。越高耗时越长。",
|
||||
"Frame Generation": "帧生成",
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "在每两个真实帧之间插入一个生成帧:更流畅,延迟略增。",
|
||||
"Shadow Cache": "阴影缓存",
|
||||
"Reuses the pack shadow map when sun, view and world match. Off by default.": "太阳、视角和世界不变时重复使用资源包的阴影贴图。默认关闭。",
|
||||
"Synchronous": "同步",
|
||||
"Asynchronous": "异步",
|
||||
"Solid": "实心",
|
||||
"Solid Aggressive": "激进实心",
|
||||
"Hollow": "镂空",
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "树冠内部的树叶绘制为实心:透明层更少。",
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges.": "只有一面外露的树叶也绘制为实心:更快,边缘更粗糙。",
|
||||
"Drops faces between leaves: faster, but gaps look through.": "省略树叶之间的面:更快,但缝隙会透视。",
|
||||
"Hotbar: Previous Slot": "快捷栏:上一个栏位",
|
||||
"Hotbar: Next Slot": "快捷栏:下一个栏位",
|
||||
"horizon.agent.connected.one": "已连接:1 个代理连接",
|
||||
"horizon.agent.connected.many": "已连接:%s 个代理连接",
|
||||
"horizon.agent.connected.one_ago": "已连接:1 个代理连接,上次命令在 %s 秒前",
|
||||
"horizon.agent.connected.many_ago": "已连接:%s 个代理连接,上次命令在 %s 秒前",
|
||||
"horizon.agent.waiting": "正在等待代理。",
|
||||
"horizon.agent.off": "关闭:代理无法访问此游戏。",
|
||||
"horizon.agent.session_opened": "由启动游戏的程序为本次会话开启。",
|
||||
"horizon.agent.socket": "套接字:%s(复制详细信息以获取完整路径)",
|
||||
"horizon.agent.local_only": "仅限本机:此电脑上的代理可用,网络不可用。"
|
||||
}
|
||||
@@ -536,6 +536,9 @@ pub fn main() -> Result<()> {
|
||||
let flying = op["value"].as_bool().unwrap();
|
||||
world.player.may_fly = op["may_fly"].as_bool().unwrap_or(flying);
|
||||
world.player.flying = flying;
|
||||
if let Some(speed) = op.get("speed") {
|
||||
world.player.flying_speed = single(speed);
|
||||
}
|
||||
}
|
||||
"trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32,
|
||||
"delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32,
|
||||
|
||||
@@ -214,6 +214,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
|
||||
cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves",
|
||||
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
|
||||
["horizon-ctl hello"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
|
||||
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
|
||||
@@ -444,7 +447,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
|
||||
@@ -19,6 +19,30 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// A recipe ingredient of a mod's own kind: the entry named a type in a
|
||||
/// namespace outside `minecraft:`, and the loader kept it for this call.
|
||||
/// The kind and the JSON the file wrote are here; fill `items` with the
|
||||
/// item ids the ingredient matches (item ids only: components are not
|
||||
/// checked here; a match-time hook follows separately). Duplicates are
|
||||
/// removed keeping first-seen order. The hook may be called more than
|
||||
/// once per recipe; the answer must be the same. An unclaimed kind
|
||||
/// leaves the list empty and the recipe fails, as an unknown kind does.
|
||||
/// API @API:ingredient@.
|
||||
#[inline(never)]
|
||||
pub fn native_ingredient(
|
||||
kind: &str,
|
||||
data: &serde_json::Map<String, Value>,
|
||||
items: &mut Vec<String>,
|
||||
) {
|
||||
let _ = (kind, data, items);
|
||||
}
|
||||
|
||||
/// Keep first-seen order, drop the rest.
|
||||
fn dedup_keep_order(items: &mut Vec<String>) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
items.retain(|i| seen.insert(i.clone()));
|
||||
}
|
||||
|
||||
/// A set of accepted items (expanded from an item, list or tag), and the
|
||||
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -107,6 +131,14 @@ impl Ingredient {
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
// A mod's own kind: the hook answers with the item list it
|
||||
// matches; a bare type name is minecraft's own, not a mod's.
|
||||
k if k.contains(':') && !k.starts_with("minecraft:") => {
|
||||
let mut list = Vec::new();
|
||||
native_ingredient(k, o, &mut list);
|
||||
dedup_keep_order(&mut list);
|
||||
return (!list.is_empty()).then(|| Self::of(list));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
(!kept.is_empty()).then(|| Self::of(kept))
|
||||
@@ -1913,6 +1945,23 @@ mod tests {
|
||||
|
||||
/// NeoForge's standard ingredient shapes resolve to their flat item
|
||||
/// lists; a kind the loader doesn't know still fails the file.
|
||||
#[test]
|
||||
fn an_unclaimed_ingredient_kind_fails_and_dedup_keeps_order() {
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
|
||||
assert!(
|
||||
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items,)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
Ingredient::parse(&json(r#"{"type":"minecraft:future_shape"}"#), &items,).is_none()
|
||||
);
|
||||
assert!(Ingredient::parse(&json(r#"{"type":"aspect"}"#), &items,).is_none());
|
||||
let mut list = vec!["b".to_string(), "a".to_string(), "b".to_string()];
|
||||
dedup_keep_order(&mut list);
|
||||
assert_eq!(list, vec!["b".to_string(), "a".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neoforge_ingredient_shapes_parse() {
|
||||
let mut tags = HashMap::new();
|
||||
|
||||
@@ -2343,6 +2343,20 @@ pub fn fireball_to_nbt(
|
||||
if let Some(power) = power {
|
||||
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
|
||||
}
|
||||
// Fireball.addAdditionalSaveData: the item it is drawn as, one of the loaded
|
||||
// item or else a fire charge (`setItem` refuses an empty stack and keeps one).
|
||||
if matches!(
|
||||
p.kind,
|
||||
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
|
||||
) {
|
||||
let mut item = tag
|
||||
.get("Item")
|
||||
.and_then(item_from_nbt)
|
||||
.filter(|i| i.count > 0 && &*i.item != "minecraft:air")
|
||||
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:fire_charge", 1));
|
||||
item.count = 1;
|
||||
put(&mut tag, "Item", item_to_nbt(&item));
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
@@ -4673,6 +4687,61 @@ mod fireball_nbt_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Fireball.addAdditionalSaveData` / `readAdditionalSaveData`: both fireballs save
|
||||
/// `Item`, a fire charge by default, and a loaded one stays, as one item.
|
||||
#[test]
|
||||
fn a_fireball_saves_its_item() {
|
||||
let item_of = |tag: &Tag| {
|
||||
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
|
||||
(item.item.to_string(), item.count)
|
||||
};
|
||||
for id in ["minecraft:fireball", "minecraft:small_fireball"] {
|
||||
let plain = Tag::compound([
|
||||
("id", Tag::String(id.into())),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
]);
|
||||
let p = fireball_from_nbt(&plain, None).unwrap();
|
||||
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&plain)).unwrap();
|
||||
assert_eq!(
|
||||
item_of(&back),
|
||||
("minecraft:fire_charge".to_owned(), 1),
|
||||
"{id}"
|
||||
);
|
||||
// A loaded item stays, as a single one.
|
||||
let mut loaded = plain.clone();
|
||||
put(
|
||||
&mut loaded,
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new("minecraft:diamond", 5)),
|
||||
);
|
||||
let p = fireball_from_nbt(&loaded, None).unwrap();
|
||||
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&loaded)).unwrap();
|
||||
assert_eq!(item_of(&back), ("minecraft:diamond".to_owned(), 1), "{id}");
|
||||
// An empty one falls back to the fire charge.
|
||||
let mut empty = plain.clone();
|
||||
put(
|
||||
&mut empty,
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new("minecraft:air", 1)),
|
||||
);
|
||||
let p = fireball_from_nbt(&empty, None).unwrap();
|
||||
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&empty)).unwrap();
|
||||
assert_eq!(
|
||||
item_of(&back),
|
||||
("minecraft:fire_charge".to_owned(), 1),
|
||||
"{id}"
|
||||
);
|
||||
}
|
||||
// The wind charges are not `Fireball`s: they save no item.
|
||||
let wind = Tag::compound([
|
||||
("id", Tag::String("minecraft:wind_charge".into())),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
]);
|
||||
let p = fireball_from_nbt(&wind, None).unwrap();
|
||||
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, Some(&wind)).unwrap();
|
||||
assert!(back.get("Item").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
|
||||
let player = Some([5, 6, 7, 8]);
|
||||
|
||||
@@ -541,6 +541,8 @@ pub struct HandSway {
|
||||
pub y_bob: f32,
|
||||
pub x_bob_o: f32,
|
||||
pub y_bob_o: f32,
|
||||
/// The yaw `tick_wrapped` has followed so far, unwrapped like vanilla's.
|
||||
y_rot: f32,
|
||||
}
|
||||
impl HandSway {
|
||||
/// `LocalPlayer.applyInput` with the player's `getXRot` and `getYRot`.
|
||||
@@ -548,9 +550,14 @@ impl HandSway {
|
||||
self.y_bob_o = self.y_bob;
|
||||
self.x_bob_o = self.x_bob;
|
||||
self.x_bob += (x_rot - self.x_bob) * 0.5;
|
||||
// Vanilla's yaw runs on without wrapping, so `yRot - yBob` is the short way round. Ours
|
||||
// is kept in -180..180 (a boat turns its rider through it), so take the short way here.
|
||||
self.y_bob += wrap_degrees(y_rot - self.y_bob) * 0.5;
|
||||
self.y_bob += (y_rot - self.y_bob) * 0.5;
|
||||
}
|
||||
/// `tick` for a yaw kept in -180..180 (a boat turns its rider through the wrap). Vanilla's
|
||||
/// yaw runs on without wrapping, so `yRot - yBob` is always the short way round; this
|
||||
/// follows the wrapped yaw by its short steps to give `tick` the same unwrapped yaw.
|
||||
pub fn tick_wrapped(&mut self, x_rot: f32, y_rot: f32) {
|
||||
self.y_rot += wrap_degrees(y_rot - self.y_rot);
|
||||
self.tick(x_rot, self.y_rot);
|
||||
}
|
||||
/// The degrees the hands turn about X, then Y, before either is drawn:
|
||||
/// `(viewXRot - xBob) * 0.1` and `(viewYRot - yBob) * 0.1`, the bobs
|
||||
@@ -1406,12 +1413,12 @@ mod action_result_tests {
|
||||
#[test]
|
||||
fn hand_sway_takes_the_short_way_over_the_yaw_wrap() {
|
||||
let mut sway = HandSway::default();
|
||||
sway.tick(0.0, 179.0);
|
||||
sway.tick_wrapped(0.0, 179.0);
|
||||
for _ in 0..20 {
|
||||
sway.tick(0.0, 179.0);
|
||||
sway.tick_wrapped(0.0, 179.0);
|
||||
}
|
||||
// The view is still at 179; one tick later it reads -179 (2 degrees on).
|
||||
sway.tick(0.0, -179.0);
|
||||
sway.tick_wrapped(0.0, -179.0);
|
||||
let [_, y] = sway.rotation(1.0, 0.0, -179.0);
|
||||
assert!(
|
||||
y.abs() < 0.5,
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The furnace methods that read the recipes.
|
||||
pub trait FurnaceCooking {
|
||||
@@ -22,15 +21,15 @@ pub trait FurnaceCooking {
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
|
||||
}
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
/// `drops` collects what the furnace lets fall at its position
|
||||
/// (`Containers.dropItemStack`): the remainder of a fuel when more of it is left.
|
||||
fn tick(
|
||||
&mut self,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
providers: &Providers,
|
||||
drops: &mut Vec<ItemStack>,
|
||||
) -> Option<bool>;
|
||||
}
|
||||
impl FurnaceCooking for Furnace {
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
@@ -59,7 +58,13 @@ impl FurnaceCooking for Furnace {
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
|
||||
fn tick(
|
||||
&mut self,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
providers: &Providers,
|
||||
drops: &mut Vec<ItemStack>,
|
||||
) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
@@ -101,16 +106,19 @@ impl FurnaceCooking for Furnace {
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
// AbstractFurnaceBlockEntity.consumeFuel: the fuel's crafting
|
||||
// remainder takes the slot once the stack is used up, else
|
||||
// it is dropped.
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
let remainder = items
|
||||
.get(&f.item)
|
||||
.and_then(|i| i.crafting_remainder.as_deref())
|
||||
.map(|r| ItemStack::new(r, 1));
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
self.items[1] = remainder;
|
||||
} else if let Some(remainder) = remainder {
|
||||
drops.push(remainder);
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
@@ -171,3 +179,98 @@ fn total_time(time: u32, speed: f32) -> i32 {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{containers::FurnaceKind, items::Items};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{collections::HashMap, fs};
|
||||
|
||||
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
|
||||
/// data root holding that one recipe and an item table with two fuels: `test:coal`
|
||||
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
|
||||
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-furnace-remainder-{}-{}-{count}",
|
||||
std::process::id(),
|
||||
fuel.replace(':', "-")
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/test_ingot.json"),
|
||||
br#"{"type":"minecraft:smelting","ingredient":"test:ore","result":{"id":"test:ingot"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(root.join("data/minecraft/loot_table")).unwrap();
|
||||
let fuel_component =
|
||||
r#"{"minecraft:cooking_fuel": {"burn_time": 200, "speed_multiplier": 1.0}}"#;
|
||||
fs::write(
|
||||
root.join("items.json"),
|
||||
format!(
|
||||
r#"{{"test:ore": {{}}, "test:ingot": {{}}, "test:coal": {fuel_component}, "test:cell": {fuel_component}, "test:husk": {{}}}}"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join(horizon_items::items::MOD_CRAFT_REMAINDERS),
|
||||
r#"{"test:cell": "test:husk"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let providers = Providers::load(&root);
|
||||
let mut furnace = Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [
|
||||
Some(ItemStack::new("test:ore", 1)),
|
||||
Some(ItemStack::new(fuel, count)),
|
||||
None,
|
||||
],
|
||||
lit_remaining: 0,
|
||||
lit_total: 0,
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
let mut drops = Vec::new();
|
||||
let lit = furnace.tick(&recipes, &items, &providers, &mut drops);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert_eq!(lit, Some(true), "the fuel lit the furnace");
|
||||
(furnace, drops)
|
||||
}
|
||||
|
||||
fn fuel_slot(furnace: &Furnace) -> Option<(&str, u32)> {
|
||||
furnace.items[1].as_ref().map(|s| (&*s.item, s.count))
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.consumeFuel`: the last of a fuel with a remainder
|
||||
/// leaves it in the fuel slot.
|
||||
#[test]
|
||||
fn the_last_fuel_leaves_its_remainder_in_the_slot() {
|
||||
let (furnace, drops) = burn("test:cell", 1);
|
||||
assert_eq!(fuel_slot(&furnace), Some(("test:husk", 1)));
|
||||
assert!(drops.is_empty());
|
||||
}
|
||||
|
||||
/// With more of the fuel left, the remainder is dropped at the furnace.
|
||||
#[test]
|
||||
fn a_fuel_still_in_the_stack_drops_its_remainder() {
|
||||
let (furnace, drops) = burn("test:cell", 3);
|
||||
assert_eq!(fuel_slot(&furnace), Some(("test:cell", 2)));
|
||||
assert_eq!(drops, vec![ItemStack::new("test:husk", 1)]);
|
||||
}
|
||||
|
||||
/// A fuel without a remainder leaves nothing.
|
||||
#[test]
|
||||
fn a_fuel_without_a_remainder_leaves_nothing() {
|
||||
let (furnace, drops) = burn("test:coal", 1);
|
||||
assert_eq!(fuel_slot(&furnace), None);
|
||||
assert!(drops.is_empty());
|
||||
let (furnace, drops) = burn("test:coal", 2);
|
||||
assert_eq!(fuel_slot(&furnace), Some(("test:coal", 1)));
|
||||
assert!(drops.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ mod dragon;
|
||||
mod dynamic_lights;
|
||||
mod enchant_effects;
|
||||
mod end;
|
||||
mod entity;
|
||||
mod entity_culling;
|
||||
mod event_loop;
|
||||
mod events;
|
||||
@@ -2677,6 +2678,10 @@ struct Game<'a> {
|
||||
cart_models: minecarts::CartModels,
|
||||
/// The boat the player rides.
|
||||
riding: Option<u32>,
|
||||
/// The mount the last `position_rider` put the player on, to tell the tick
|
||||
/// a seat is taken (`Entity.moveTo`, no camera sweep) from the ones that
|
||||
/// follow it (`positionRider`, interpolated as usual).
|
||||
posed_rider: Option<u32>,
|
||||
/// The vehicle the player's file names, waiting for it to be in the world
|
||||
/// (`ServerPlayer.loadAndSpawnParentVehicle`).
|
||||
pending_vehicle: Option<[i32; 4]>,
|
||||
@@ -2849,6 +2854,13 @@ struct Game<'a> {
|
||||
/// `remote_handle_input`): picked once, so a creative break does not
|
||||
/// move the use that follows it past the broken block.
|
||||
tick_hit: Option<Option<Hit>>,
|
||||
/// The mob of that `hitResult`, picked with the item held at the pick
|
||||
/// (`LocalPlayer.raycastHitResult`: a spear's `attack_range`), before the
|
||||
/// tick's number keys change it.
|
||||
tick_mob: Option<Option<(usize, f64)>>,
|
||||
/// Number keys since the last tick (`keyHotbarSlots[i].consumeClick`):
|
||||
/// `Minecraft.handleKeybinds` selects them after the tick's pick.
|
||||
hotbar_presses: Vec<usize>,
|
||||
/// `Player.entityInteractionRange` and `blockInteractionRange`, refreshed
|
||||
/// each tick from the attribute (a server's reach modifier, creative's
|
||||
/// +2): the pick helpers' `&self` reach.
|
||||
@@ -7150,6 +7162,11 @@ impl Game<'_> {
|
||||
self.tick_riding(keys)?;
|
||||
}
|
||||
let riding = self.riding.is_some();
|
||||
// A seat that is given up is taken again as a new one: the next mount
|
||||
// snaps the camera even when it is the same vehicle.
|
||||
if !riding {
|
||||
self.posed_rider = None;
|
||||
}
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let spin_targets = self.spin_targets();
|
||||
let sprint_window = self.settings.sprint_window;
|
||||
@@ -8554,7 +8571,7 @@ impl Game<'_> {
|
||||
batch.blur_at = batch.vertices.len() as u32;
|
||||
// PackConfirmScreen takes the open screen's place (it comes back once
|
||||
// the prompt is answered): the screen under the prompt isn't drawn.
|
||||
let screen = if self.server_packs.prompt.is_some() {
|
||||
let screen = if self.server_packs.prompt.is_some() || self.book.is_some() {
|
||||
Screen::Playing
|
||||
} else {
|
||||
self.screen
|
||||
@@ -8627,7 +8644,7 @@ impl Game<'_> {
|
||||
/// read-only: what a mod draws is its own.
|
||||
fn fire_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
let screen = self.screen;
|
||||
if screen == Screen::Playing || self.server_packs.prompt.is_some() {
|
||||
if screen == Screen::Playing || self.server_packs.prompt.is_some() || self.book.is_some() {
|
||||
return;
|
||||
}
|
||||
// slot_count mirrors slots() without allocating; keep them in step.
|
||||
@@ -10456,6 +10473,7 @@ impl Game<'_> {
|
||||
let mut crafted = 0;
|
||||
while let Some((recipe, result)) = self.craft_recipe() {
|
||||
let made = (result.item.clone(), result.count);
|
||||
let taken = result.clone();
|
||||
let cells = self.craft_size * self.craft_size;
|
||||
let ingredients = self.craft_grid[..cells].to_vec();
|
||||
let remainders = world_recipes!(self).remainders(
|
||||
@@ -10496,6 +10514,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
crafted += 1;
|
||||
// `ResultSlot.onTake`: the recipe and the stack taken
|
||||
// (native_item_crafted).
|
||||
let player = self.native_acting_id();
|
||||
self.native_item_crafted(player, &format!("minecraft:{recipe}"), &taken);
|
||||
self.stats.add("crafted", &made.0, made.1 as i32);
|
||||
self.award_crafted(&[(format!("minecraft:{recipe}"), ingredients)]);
|
||||
if !shift || crafted >= 64 {
|
||||
@@ -10589,6 +10611,8 @@ impl Game<'_> {
|
||||
alpha: f64,
|
||||
mut out: Vec<Vertex>,
|
||||
) -> Vec<Vertex> {
|
||||
// Its steps as spans (HORIZON_SPIKES): each lap names the step that just ended.
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
self.entity_spans.borrow_mut().clear();
|
||||
self.entity_motion.borrow_mut().clear();
|
||||
// LevelExtractor.extract: the transient blocks queued since the last frame.
|
||||
@@ -10604,6 +10628,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.yaw
|
||||
};
|
||||
laps.lap("eg_prep");
|
||||
for m in &self.mobs {
|
||||
// Blaze.isOnFire: a charged blaze burns.
|
||||
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
|
||||
@@ -10648,6 +10673,7 @@ impl Game<'_> {
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
laps.lap("eg_fire_mobs");
|
||||
for item in &self.dropped {
|
||||
if item.fire_ticks > 0
|
||||
&& !self
|
||||
@@ -10672,6 +10698,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_fire_items");
|
||||
for stand in &self.armor_stands {
|
||||
if stand.fire_ticks > 0 && !stand.marker {
|
||||
let scale = if stand.small { 0.5 } else { 1.0 };
|
||||
@@ -10692,6 +10719,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_fire_stands");
|
||||
// `ItemEntityRenderer`.
|
||||
self.mark_entity(out.len(), "minecraft:item");
|
||||
self.dropped_item_geometry(camera, alpha, &mut out);
|
||||
@@ -10706,9 +10734,11 @@ impl Game<'_> {
|
||||
self.shelf_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
laps.lap("eg_items_misc");
|
||||
// Mobs and their projectiles (a span each).
|
||||
self.mob_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
laps.lap("eg_mobs");
|
||||
// In first person only a shader pack's shadow pass draws the player.
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
@@ -10725,6 +10755,7 @@ impl Game<'_> {
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
self.fangs_geometry(camera, alpha, &mut out);
|
||||
self.block_item_display_geometry(camera, alpha, &mut out);
|
||||
laps.lap("eg_players");
|
||||
let mut see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
see_through.clear();
|
||||
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
@@ -10732,6 +10763,7 @@ impl Game<'_> {
|
||||
self.debug_renderer_geometry(camera, &mut out);
|
||||
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.see_through_vertices = see_through;
|
||||
laps.lap("eg_name_tags");
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
|
||||
// A native shader pack's sun shadows already fall under the entities in the Overworld:
|
||||
@@ -10797,6 +10829,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_shadows");
|
||||
// Block entities (BlockEntityRenderers): chests, shulker boxes, banners, skulls...
|
||||
if let Some(r) = &self.renderer {
|
||||
let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32);
|
||||
@@ -10893,6 +10926,7 @@ impl Game<'_> {
|
||||
.render_breaking(&scene, pos, state.0, stage, &mut out);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_block_entities");
|
||||
// Experience orbs (ExperienceOrbRenderer): pulsing billboards from a 4x4 icon sheet.
|
||||
self.mark_entity(out.len(), "minecraft:experience_orb");
|
||||
if let Some(r) = self
|
||||
@@ -10962,6 +10996,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
laps.lap("eg_orbs");
|
||||
// Falling blocks use their baked model, including non-cubic shapes.
|
||||
self.mark_entity(out.len(), "minecraft:falling_block");
|
||||
for f in &self.falling {
|
||||
@@ -11026,6 +11061,7 @@ impl Game<'_> {
|
||||
self.rocket_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:splash_potion");
|
||||
self.thrown_potion_geometry(camera, alpha, &mut out);
|
||||
laps.lap("eg_objects");
|
||||
// Particles (ParticleEngine.extract): camera-facing quads.
|
||||
self.mark_stage(out.len(), "PARTICLES");
|
||||
let world = &self.world;
|
||||
@@ -11037,6 +11073,7 @@ impl Game<'_> {
|
||||
&|b| world.light(b[0], b[1], b[2]),
|
||||
&mut out,
|
||||
);
|
||||
laps.lap("eg_particles");
|
||||
// Breaking progress overlays (destroy_stage_0..9): ours and the other players'.
|
||||
self.mark_stage(out.len(), "DESTROY");
|
||||
for &(pos, stage) in &cracks {
|
||||
@@ -11067,7 +11104,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
laps.lap("eg_cracks");
|
||||
self.finish_async_entity_culling();
|
||||
laps.lap("eg_culling_finish");
|
||||
out
|
||||
}
|
||||
|
||||
@@ -11163,6 +11202,9 @@ impl Game<'_> {
|
||||
darken
|
||||
}
|
||||
fn mob_hit(&self) -> Option<(usize, f64)> {
|
||||
if let Some(hit) = self.tick_mob {
|
||||
return hit;
|
||||
}
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach: f64 = self.entity_reach;
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
@@ -11386,7 +11428,7 @@ impl Game<'_> {
|
||||
let Some((index, distance)) = self.mob_hit() else {
|
||||
return false;
|
||||
};
|
||||
if !self.before_block(distance) {
|
||||
if !self.before_block(distance) || !self.held_range_allows(distance) {
|
||||
return false;
|
||||
}
|
||||
self.attack_mob(index);
|
||||
@@ -15945,6 +15987,8 @@ impl Game<'_> {
|
||||
KeyCode::KeyE => match self.screen {
|
||||
Screen::Playing if self.spectator() => {}
|
||||
Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.inventory.
|
||||
self.select_queued_hotbar();
|
||||
// key.inventory while riding a tamed mount opens its inventory.
|
||||
if !self.open_ridden_mount_menu() {
|
||||
if self.tutorial.open_inventory() {
|
||||
@@ -15985,6 +16029,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
KeyCode::KeyF if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.swapOffhand.
|
||||
self.select_queued_hotbar();
|
||||
// handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for
|
||||
// the off hand, then the main hand.
|
||||
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
|
||||
@@ -15996,6 +16042,8 @@ impl Game<'_> {
|
||||
self.using = None;
|
||||
}
|
||||
KeyCode::KeyQ if self.screen == Screen::Playing => {
|
||||
// handleKeybinds: the number keys select before key.drop.
|
||||
self.select_queued_hotbar();
|
||||
let all = drop_whole_stack(self.modifiers);
|
||||
self.drop_held(all);
|
||||
}
|
||||
@@ -16033,7 +16081,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.hotbar_load_or_save(slot, load);
|
||||
} else {
|
||||
self.selected = slot;
|
||||
self.hotbar_presses.push(slot);
|
||||
}
|
||||
}
|
||||
_ if self.screen == Screen::Paused && self.pause_menu => {
|
||||
@@ -16084,6 +16132,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
MouseButton::Middle if pressed => {
|
||||
// handleKeybinds: the number keys select before key.pickItem.
|
||||
self.select_queued_hotbar();
|
||||
// Minecraft.pickBlockOrEntity -> the server's handlePickItemFromBlock:
|
||||
// the block's clone item, with its block entity's data on Ctrl in
|
||||
// creative, picked from the inventory (`pick_item`).
|
||||
@@ -18418,6 +18468,7 @@ impl<'a> Game<'a> {
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
posed_rider: None,
|
||||
pending_vehicle: None,
|
||||
ender_items: vec![None; 27],
|
||||
ender_open: None,
|
||||
@@ -18513,6 +18564,8 @@ impl<'a> Game<'a> {
|
||||
world_icon: None,
|
||||
forced_hit: None,
|
||||
tick_hit: None,
|
||||
tick_mob: None,
|
||||
hotbar_presses: Vec::new(),
|
||||
entity_reach: 3.0,
|
||||
block_reach: 4.5,
|
||||
guest_use: None,
|
||||
@@ -20645,6 +20698,15 @@ pub fn run_with() -> Result<()> {
|
||||
horizon_threads::catch_stop_signals();
|
||||
}
|
||||
let started = Instant::now();
|
||||
// A dedicated server writes its slow ticks to the log with the spans
|
||||
// they spent their time in (`HORIZON_SPIKES`'s line; the variable still
|
||||
// sets another threshold), so a lag spike players felt can be traced
|
||||
// from the log alone. Timing the spans costs microseconds a tick.
|
||||
if options.spikes.is_none()
|
||||
&& options.execution_role == execution_role::ExecutionRole::DedicatedServer
|
||||
{
|
||||
options.spikes = Some(dedicated::SLOW_TICK_MS);
|
||||
}
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
|
||||
@@ -86,34 +86,21 @@ impl Game<'_> {
|
||||
let server = self
|
||||
.control
|
||||
.as_ref()
|
||||
.filter(|c| !c.path().as_os_str().is_empty());
|
||||
let mut lines = vec![match server {
|
||||
Some(s) if s.clients() > 0 => {
|
||||
let ago = self
|
||||
.agent_last
|
||||
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"Connected: {} agent connection{}{ago}",
|
||||
s.clients(),
|
||||
if s.clients() == 1 { "" } else { "s" }
|
||||
)
|
||||
}
|
||||
Some(_) => "Waiting for an agent.".into(),
|
||||
None => "Off: agents can't reach this game.".into(),
|
||||
}];
|
||||
if let Some(s) = server {
|
||||
if self.options.control.is_some() && !self.agent_control_on {
|
||||
lines.push("Opened for this session by the program that started the game.".into());
|
||||
}
|
||||
// The full path is too wide for the page: Copy Connection Details has it.
|
||||
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
|
||||
lines.push(format!(
|
||||
"Socket: {name} (copy the details for its full path)"
|
||||
));
|
||||
}
|
||||
lines.push("Local only: agents on this computer, never the network.".into());
|
||||
lines
|
||||
.filter(|c| !c.path().as_os_str().is_empty())
|
||||
.map(|s| Socket {
|
||||
clients: s.clients(),
|
||||
name: s
|
||||
.path()
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
});
|
||||
status_lines(
|
||||
server,
|
||||
self.agent_last.map(|t| t.elapsed().as_secs()),
|
||||
self.options.control.is_some() && !self.agent_control_on,
|
||||
)
|
||||
}
|
||||
|
||||
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
|
||||
@@ -138,3 +125,127 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The control socket as the AI Agent page tells of it.
|
||||
struct Socket {
|
||||
/// Agents connected now.
|
||||
clients: usize,
|
||||
/// The socket's file name; the full path is too wide for the page (Copy Connection Details
|
||||
/// has it).
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// The AI Agent page's lines in the selected language: the state, then where agents connect.
|
||||
/// `last_command` is the seconds since an agent's last command, `session_only` whether the
|
||||
/// program that started the game opened the socket and the player's switch did not.
|
||||
fn status_lines(
|
||||
server: Option<Socket>,
|
||||
last_command: Option<u64>,
|
||||
session_only: bool,
|
||||
) -> Vec<String> {
|
||||
use horizon_client::lang::{get, tr};
|
||||
let mut lines = vec![match &server {
|
||||
Some(s) if s.clients > 0 => {
|
||||
let (one, many) = match last_command {
|
||||
Some(_) => ("one_ago", "many_ago"),
|
||||
None => ("one", "many"),
|
||||
};
|
||||
let key = format!(
|
||||
"horizon.agent.connected.{}",
|
||||
if s.clients == 1 { one } else { many }
|
||||
);
|
||||
let mut args: Vec<&dyn std::fmt::Display> = Vec::new();
|
||||
if s.clients != 1 {
|
||||
args.push(&s.clients);
|
||||
}
|
||||
if let Some(ago) = &last_command {
|
||||
args.push(ago);
|
||||
}
|
||||
tr(&key, &args)
|
||||
}
|
||||
Some(_) => get("horizon.agent.waiting"),
|
||||
None => get("horizon.agent.off"),
|
||||
}];
|
||||
if let Some(s) = &server {
|
||||
if session_only {
|
||||
lines.push(get("horizon.agent.session_opened"));
|
||||
}
|
||||
lines.push(tr("horizon.agent.socket", &[&s.name]));
|
||||
}
|
||||
lines.push(get("horizon.agent.local_only"));
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Socket, status_lines};
|
||||
use horizon_client::lang::{Language, scoped};
|
||||
|
||||
fn lines(code: &str, clients: usize, ago: Option<u64>, session_only: bool) -> Vec<String> {
|
||||
let socket = Socket {
|
||||
clients,
|
||||
name: "ctl.sock".into(),
|
||||
};
|
||||
let (lines, missed) = scoped(Language::load(&[], code), || {
|
||||
status_lines(Some(socket), ago, session_only)
|
||||
});
|
||||
assert!(missed.is_empty(), "{code}: {missed:?}");
|
||||
lines
|
||||
}
|
||||
|
||||
/// The AI Agent page told its state in English in every language (#1927).
|
||||
#[test]
|
||||
fn agent_page_lines_follow_the_language() {
|
||||
assert_eq!(
|
||||
lines("de_de", 2, Some(5), false),
|
||||
[
|
||||
"Verbunden: 2 Agentenverbindungen, letzter Befehl vor 5 s",
|
||||
"Socket: ctl.sock (Verbindungsdaten kopieren für den vollständigen Pfad)",
|
||||
"Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
lines("pl_pl", 1, None, true),
|
||||
[
|
||||
"Połączono: 1 połączenie agenta",
|
||||
"Otwarte na tę sesję przez program, który uruchomił grę.",
|
||||
"Gniazdo: ctl.sock (skopiuj dane, aby poznać pełną ścieżkę)",
|
||||
"Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
|
||||
]
|
||||
);
|
||||
let (off, missed) = scoped(Language::load(&[], "fr_fr"), || {
|
||||
status_lines(None, None, false)
|
||||
});
|
||||
assert!(missed.is_empty(), "{missed:?}");
|
||||
assert_eq!(
|
||||
off,
|
||||
[
|
||||
"Désactivé : les agents ne peuvent pas joindre ce jeu.",
|
||||
"Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// English keeps the sentences it always had: the count, its plural, the time since the last
|
||||
/// command, and the wait for a first agent.
|
||||
#[test]
|
||||
fn agent_page_lines_in_english() {
|
||||
assert_eq!(
|
||||
lines("en_us", 2, Some(5), false)[0],
|
||||
"Connected: 2 agent connections, last command 5 s ago"
|
||||
);
|
||||
assert_eq!(
|
||||
lines("en_us", 1, None, false)[0],
|
||||
"Connected: 1 agent connection"
|
||||
);
|
||||
assert_eq!(
|
||||
lines("en_us", 1, Some(0), false)[0],
|
||||
"Connected: 1 agent connection, last command 0 s ago"
|
||||
);
|
||||
assert_eq!(
|
||||
lines("en_us", 3, None, false)[0],
|
||||
"Connected: 3 agent connections"
|
||||
);
|
||||
assert_eq!(lines("en_us", 0, None, true)[0], "Waiting for an agent.");
|
||||
}
|
||||
}
|
||||
@@ -218,11 +218,17 @@ impl Game<'_> {
|
||||
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(lit) = f.tick(
|
||||
let mut drops = Vec::new();
|
||||
let lit = f.tick(
|
||||
world_recipes!(self),
|
||||
world_items!(self),
|
||||
self.res.providers(),
|
||||
) else {
|
||||
&mut drops,
|
||||
);
|
||||
for stack in drops {
|
||||
self.campfire_drop(pos, stack);
|
||||
}
|
||||
let Some(lit) = lit else {
|
||||
return Ok(());
|
||||
};
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
|
||||
@@ -258,7 +258,7 @@ impl Game<'_> {
|
||||
self.remote_close_container();
|
||||
}
|
||||
self.book = None;
|
||||
self.capture(true);
|
||||
self.capture(self.screen == super::Screen::Playing && !self.screen_over_playing());
|
||||
}
|
||||
/// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close:
|
||||
/// trailing empty pages dropped, the book in hand updated here and, on a
|
||||
|
||||
@@ -171,7 +171,7 @@ impl Game<'_> {
|
||||
|
||||
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
|
||||
/// integer position.
|
||||
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
|
||||
pub(super) fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
for piece in
|
||||
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
|
||||
|
||||
@@ -574,6 +574,8 @@ impl Game<'_> {
|
||||
/// last time, when this method can restore it (a command draft only for
|
||||
/// /), else with its prefix.
|
||||
pub(super) fn open_chat_method(&mut self, prefix: &str) {
|
||||
// handleKeybinds: the number keys select before key.chat and key.command.
|
||||
self.select_queued_hotbar();
|
||||
let command = prefix == "/";
|
||||
let draft = self
|
||||
.ui
|
||||
|
||||
@@ -3000,6 +3000,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `MinecraftServer.setDifficulty(difficulty, true)`.
|
||||
pub(super) fn set_difficulty_forced(&mut self, i: u8) {
|
||||
let from = self.ui.difficulty;
|
||||
self.ui.difficulty = i;
|
||||
// The level data holds the world's difficulty: it survives the
|
||||
// reopen, so it follows the command.
|
||||
@@ -3018,6 +3019,10 @@ impl Game<'_> {
|
||||
locked: self.level.difficulty_locked,
|
||||
}));
|
||||
}
|
||||
if from != i {
|
||||
let (from, to) = difficulty_names(from, i);
|
||||
self.native_difficulty_changed(from, to);
|
||||
}
|
||||
}
|
||||
fn cmd_spawnpoint(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
@@ -3744,6 +3749,30 @@ pub(super) fn block_input_matches(
|
||||
.all(|(k, v)| s.properties.get(k) == Some(v))
|
||||
}
|
||||
|
||||
/// The names `native_difficulty_changed` hears for a change from `from` to
|
||||
/// `to`: the four difficulties of `options.difficulty.*`, the index kept in
|
||||
/// range as the command and the options keep it.
|
||||
fn difficulty_names(from: u8, to: u8) -> (&'static str, &'static str) {
|
||||
(
|
||||
DIFFICULTIES[usize::from(from) & 3],
|
||||
DIFFICULTIES[usize::from(to) & 3],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod difficulty_tests {
|
||||
use super::difficulty_names;
|
||||
|
||||
#[test]
|
||||
fn a_difficulty_change_names_both_ends() {
|
||||
assert_eq!(difficulty_names(0, 3), ("peaceful", "hard"));
|
||||
assert_eq!(difficulty_names(2, 1), ("normal", "easy"));
|
||||
// The four difficulties are the only indexes the command and the
|
||||
// options keep; an index outside them still names one.
|
||||
assert_eq!(difficulty_names(7, 4), ("hard", "peaceful"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod summon_tests {
|
||||
#[test]
|
||||
|
||||
@@ -68,7 +68,12 @@ impl Game<'_> {
|
||||
.flatten()
|
||||
}
|
||||
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
|
||||
let player_uuid = self.level.player_uuid;
|
||||
// The acting player's own UUID: a guest's turn (the window's player on
|
||||
// the server thread) keeps the world owner's in `level`.
|
||||
let player_uuid = self
|
||||
.lan_acting_uuid
|
||||
.map(horizon_client::lan::playerdata::uuid_ints)
|
||||
.or(self.level.player_uuid);
|
||||
Some(match t {
|
||||
Target::Guest(_) => {
|
||||
return self
|
||||
|
||||
@@ -10,7 +10,27 @@ use std::{
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// The control protocol's version (`docs/api/stability.md`): before 1.0 an
|
||||
/// addition makes the next patch and a breaking change the next line.
|
||||
const PROTOCOL: &str = "0.1.0";
|
||||
|
||||
/// The command groups the game serves, as `help` chains them: what `hello` reports as its features.
|
||||
const FEATURES: &[&str] = &[
|
||||
"game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options",
|
||||
"events", "saves", "pad",
|
||||
];
|
||||
|
||||
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves.
|
||||
fn hello() -> Value {
|
||||
json!({
|
||||
"protocol": PROTOCOL,
|
||||
"release": horizon_core::release::VERSION,
|
||||
"features": FEATURES,
|
||||
})
|
||||
}
|
||||
|
||||
const HELP: &[&str] = &[
|
||||
"hello the control protocol's version, the game's release and the command groups it serves",
|
||||
"status game, player, streaming and renderer state",
|
||||
"simulation authoritative projectiles in the owner's level",
|
||||
"server-hold on|off hold simulation and player packets at a control-fixture barrier",
|
||||
@@ -36,7 +56,7 @@ const HELP: &[&str] = &[
|
||||
"type TEXT type into the open chat box (key enter tap sends)",
|
||||
"chat-log [N] the newest N chat messages (default 10)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
|
||||
"stats [reset] frame-time summary since the last reset",
|
||||
"stats [reset] [raw] frame-time summary since the last reset (raw: every frame time too)",
|
||||
"narration the narrator's status and the last messages it was asked to say",
|
||||
"bench SECONDS reset stats, wait, then reply with the summary",
|
||||
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
|
||||
@@ -355,6 +375,7 @@ impl Game<'_> {
|
||||
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
|
||||
}
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"hello" => Ok(Some(hello())),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"stall" => {
|
||||
let millis: u64 = arg(1)?.parse()?;
|
||||
@@ -859,8 +880,13 @@ impl Game<'_> {
|
||||
})))
|
||||
}
|
||||
"stats" => {
|
||||
let summary = self.summary_with_passes();
|
||||
if words.get(1) == Some(&"reset") {
|
||||
let mut summary = self.summary_with_passes();
|
||||
if words[1..].contains(&"raw") {
|
||||
summary["frame_ms"] = self.frame_stats.frame_millis().into();
|
||||
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
|
||||
summary["gpu_ms"] = self.frame_stats.gpu_millis().into();
|
||||
}
|
||||
if words[1..].contains(&"reset") {
|
||||
self.frame_stats.reset();
|
||||
}
|
||||
Ok(Some(summary))
|
||||
@@ -1403,6 +1429,25 @@ fn key_code(name: &str) -> Option<KeyCode> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hello_names_the_protocol_the_release_and_the_command_groups() {
|
||||
let reply = hello();
|
||||
assert_eq!(reply["release"], horizon_core::release::VERSION);
|
||||
let protocol = reply["protocol"].as_str().unwrap();
|
||||
let parts: Vec<_> = protocol.split('.').collect();
|
||||
assert!(
|
||||
parts.len() == 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()),
|
||||
"{protocol}"
|
||||
);
|
||||
let features = reply["features"].as_array().unwrap();
|
||||
assert!(!features.is_empty());
|
||||
let mut names: Vec<_> = features.iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(names.len(), features.len());
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), features.len(), "a group is named twice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taps_and_ups_always_release_their_key() {
|
||||
for line in ["key shift tap", "key shift up", "key Shift up"] {
|
||||
|
||||
@@ -488,14 +488,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit
|
||||
/// `MobEvent::ArmorHurt` reported: every worn piece that wears when its
|
||||
/// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking
|
||||
/// applied, and at its maximum the slot empties and the equipment-break
|
||||
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52
|
||||
/// for the armour, 65 for the body). A fire hit leaves a fire-resistant
|
||||
/// piece alone (`ItemStack.canBeHurtBy`).
|
||||
/// `MobEvent::ArmorHurt` reported. Only `Horse` overrides `hurtArmor` for a
|
||||
/// mob, and its override asks for the BODY slot alone, so the body piece is
|
||||
/// the only one this wears: it loses `max(1, damage / 4)` durability,
|
||||
/// Unbreaking applied, and at its maximum the slot empties and the break
|
||||
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 65 for the
|
||||
/// body — 49 to 52 are the four armour slots, which only a player's own
|
||||
/// pieces reach). A fire hit leaves a fire-resistant piece alone
|
||||
/// (`ItemStack.canBeHurtBy`).
|
||||
fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) {
|
||||
use horizon_client::mobs::drops;
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
if damage <= 0.0 {
|
||||
return;
|
||||
@@ -504,7 +505,6 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let body_armour = drops::head_is_body(m.species());
|
||||
let amount = ((damage / 4.0) as u32).max(1);
|
||||
let name = damage_type(kind, false);
|
||||
let fire = self
|
||||
@@ -514,9 +514,15 @@ impl Game<'_> {
|
||||
.id(name)
|
||||
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
|
||||
// A piece's maximum, or none when it may not lose durability at all.
|
||||
// `doHurtEquipment` starts from the item's `equippable` component
|
||||
// (`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), so a
|
||||
// damageable item with no such component — a sword put in a horse's body
|
||||
// slot by `/item replace` — never wears; `equip_damage_on_hurt` alone
|
||||
// cannot say that, since it defaults to true when the component is absent.
|
||||
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
|
||||
let info = me.res.items().get(&stack.item)?;
|
||||
if !info.equip_damage_on_hurt
|
||||
if info.equip_slot.is_none()
|
||||
|| !info.equip_damage_on_hurt
|
||||
|| info.max_damage == 0
|
||||
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|
||||
|| (fire && me.res.items().resists_fire(stack, name))
|
||||
@@ -525,66 +531,39 @@ impl Game<'_> {
|
||||
}
|
||||
Some(info.max_damage)
|
||||
}
|
||||
let mut broke: Vec<i8> = Vec::new();
|
||||
// The four armour slots, then the body slot (`extra.head`).
|
||||
let event_of = horizon_client::mobs::item_break::equipment_break_event;
|
||||
for (index, slot) in [
|
||||
(0usize, EquipmentSlot::Head),
|
||||
(1, EquipmentSlot::Chest),
|
||||
(2, EquipmentSlot::Legs),
|
||||
(3, EquipmentSlot::Feet),
|
||||
] {
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let Some(stack) = m.extra.armor[index].clone() else {
|
||||
continue;
|
||||
};
|
||||
let Some(max) = wearable(self, &stack, fire, name) else {
|
||||
continue;
|
||||
};
|
||||
let loss = self.durability_loss(&stack, amount);
|
||||
if loss == 0 {
|
||||
continue;
|
||||
}
|
||||
let lost = stack.damage + loss;
|
||||
let Some(body) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| m.item_by_slot(EquipmentSlot::Body))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(max) = wearable(self, &body, fire, name) else {
|
||||
return;
|
||||
};
|
||||
let loss = self.durability_loss(&body, amount);
|
||||
if loss == 0 {
|
||||
return;
|
||||
}
|
||||
let lost = body.damage + loss;
|
||||
let broke = {
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if lost >= max {
|
||||
m.extra.armor[index] = None;
|
||||
broke.push(event_of(slot));
|
||||
} else if let Some(worn) = m.extra.armor[index].as_mut() {
|
||||
worn.damage = lost;
|
||||
m.extra.head = None;
|
||||
m.extra.body_damage = 0;
|
||||
true
|
||||
} else {
|
||||
m.extra.body_damage = lost as i32;
|
||||
false
|
||||
}
|
||||
}
|
||||
if body_armour {
|
||||
let body = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| m.item_by_slot(EquipmentSlot::Body));
|
||||
if let Some(body) = body
|
||||
&& let Some(max) = wearable(self, &body, fire, name)
|
||||
{
|
||||
let loss = self.durability_loss(&body, amount);
|
||||
if loss > 0 {
|
||||
let lost = body.damage + loss;
|
||||
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
if lost >= max {
|
||||
m.extra.head = None;
|
||||
m.extra.body_damage = 0;
|
||||
broke.push(event_of(EquipmentSlot::Body));
|
||||
} else {
|
||||
m.extra.body_damage = lost as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for event in broke {
|
||||
};
|
||||
if broke {
|
||||
// LivingEntity.onEquippedItemBroken through the level's broadcast.
|
||||
let event =
|
||||
horizon_client::mobs::item_break::equipment_break_event(EquipmentSlot::Body);
|
||||
self.lan_entity_event(id as i32, event, at);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,16 @@ fn forwarding_secret(online: bool) -> Result<Option<Vec<u8>>> {
|
||||
Ok(Some(secret))
|
||||
}
|
||||
|
||||
/// `MinecraftServer.runServer`'s overload warning for a server `behind` its
|
||||
/// schedule, in milliseconds and in ticks of `tick`.
|
||||
fn overload_warning(behind: Duration, tick: Duration) -> String {
|
||||
format!(
|
||||
"Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind",
|
||||
behind.as_millis(),
|
||||
behind.as_nanos() / tick.as_nanos().max(1)
|
||||
)
|
||||
}
|
||||
|
||||
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
|
||||
pub(super) fn log(level: &str, message: &str) {
|
||||
log_from("Server thread", level, message);
|
||||
@@ -502,6 +512,13 @@ const LEGACY_PRESET_NAMES: [(&str, horizon_client::save::WorldPreset); 2] = [
|
||||
|
||||
/// server.properties' line for `server.toml`'s `max_view_distance` (horizon-server writes it).
|
||||
const FAR_PROPERTY: &str = "horizon-max-view-distance";
|
||||
/// A dedicated server logs a tick slower than this, in milliseconds, with
|
||||
/// its spans, unless `HORIZON_SPIKES` names another threshold: five ticks'
|
||||
/// worth, a stall players feel as rubber-banding.
|
||||
pub(super) const SLOW_TICK_MS: f32 = 250.0;
|
||||
/// `MinecraftServer.OVERLOADED_WARNING_INTERVAL_NANOS` plus 100 ticks: the
|
||||
/// "Can't keep up!" warning comes at most this often.
|
||||
const OVERLOAD_WARNING_INTERVAL: Duration = Duration::from_secs(15);
|
||||
/// server.properties' line for `server.toml`'s `admin_feedback_to_ops` (horizon-server writes it).
|
||||
const ADMIN_FEEDBACK_PROPERTY: &str = "horizon-admin-feedback-to-ops";
|
||||
|
||||
@@ -618,6 +635,8 @@ struct ServerState {
|
||||
/// View and simulation distance, and the player limit, for the report.
|
||||
view: (u32, u32),
|
||||
max_players: usize,
|
||||
/// The mutable `gamemode` server property (`DedicatedServer.gameMode`).
|
||||
game_mode: i32,
|
||||
}
|
||||
|
||||
/// The dedicated-only commands' replies vanilla sends with `broadcast`
|
||||
@@ -985,6 +1004,7 @@ impl Game<'_> {
|
||||
broadcast_console: props.broadcast_console_to_ops,
|
||||
broadcast_rcon: props.broadcast_rcon_to_ops,
|
||||
op_level: props.op_permission_level,
|
||||
game_mode: props.game_mode,
|
||||
report: None,
|
||||
view: (
|
||||
props.view_distance.clamp(2, 32) as u32,
|
||||
@@ -1000,6 +1020,7 @@ impl Game<'_> {
|
||||
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();
|
||||
let mut overload_warned: Option<Instant> = None;
|
||||
while !state.stop {
|
||||
let tick_start = Instant::now();
|
||||
if let Some(watchdog) = &watchdog {
|
||||
@@ -1089,7 +1110,17 @@ impl Game<'_> {
|
||||
far.brake(mspt, host.hub.config.view_distance);
|
||||
}
|
||||
state.ticked(Instant::now());
|
||||
self.wait_for_next_server_tick(&mut next, paused);
|
||||
// MinecraftServer.runServer: a server more than a second's ticks
|
||||
// behind skips them and says so, at most every 15 seconds.
|
||||
if let Some(behind) = self.wait_for_next_server_tick(&mut next, paused)
|
||||
&& overload_warned.is_none_or(|at| at.elapsed() >= OVERLOAD_WARNING_INTERVAL)
|
||||
{
|
||||
overload_warned = Some(Instant::now());
|
||||
log(
|
||||
"WARN",
|
||||
&overload_warning(behind, self.clock.tick_duration()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.running.store(false, Ordering::Relaxed);
|
||||
@@ -1975,7 +2006,7 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{capture_log, log, log_from, panic_message};
|
||||
use super::{capture_log, log, log_from, overload_warning, panic_message};
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, BufRead, BufReader, Read, Write},
|
||||
@@ -1983,6 +2014,16 @@ mod tests {
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
/// Vanilla's words, with how far behind the server is in milliseconds and in whole ticks.
|
||||
#[test]
|
||||
fn the_overload_warning_reads_as_vanilla_s() {
|
||||
let tick = std::time::Duration::from_millis(50);
|
||||
assert_eq!(
|
||||
overload_warning(std::time::Duration::from_millis(13_027), tick),
|
||||
"Can't keep up! Is the server overloaded? Running 13027ms or 260 ticks behind"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as
|
||||
//! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the
|
||||
//! players a change shuts out are disconnected. The supervisor answers with the lists, from their files.
|
||||
use super::{ServerState, log};
|
||||
use super::{GAME_MODES, ServerState, log};
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
command::vanilla::DIFFICULTIES,
|
||||
@@ -70,6 +70,25 @@ impl Game<'_> {
|
||||
self.store_property("motd", value.to_owned());
|
||||
return json!({ "value": value });
|
||||
}
|
||||
"serversettings/game_mode" => {
|
||||
return json!({ "value": GAME_MODES[state.game_mode as usize] });
|
||||
}
|
||||
"serversettings/game_mode/set" => {
|
||||
let Some(value) = body["value"].as_str() else {
|
||||
return json!({ "error": "game mode must be a string" });
|
||||
};
|
||||
let Some(index) = GAME_MODES.iter().position(|name| *name == value) else {
|
||||
return json!({ "error": format!("unknown game mode {value:?}") });
|
||||
};
|
||||
rpc(format!(
|
||||
"Update game mode from '{}' to '{}'",
|
||||
GAME_MODES[state.game_mode as usize], value
|
||||
));
|
||||
self.store_property("gamemode", value.to_owned());
|
||||
state.game_mode = index as i32;
|
||||
self.set_default_game_mode(state.game_mode);
|
||||
return json!({ "value": GAME_MODES[state.game_mode as usize] });
|
||||
}
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
|
||||
@@ -89,7 +89,17 @@ impl Game<'_> {
|
||||
/// `ItemStack.hurtAndBreak` for the player's `stack`: the wear Unbreaking
|
||||
/// leaves, and `ItemDurabilityTrigger` when some is left (`applyDamage`).
|
||||
pub(super) fn wear(&mut self, stack: &ItemStack, amount: u32) -> u32 {
|
||||
let loss = self.durability_loss(stack, amount);
|
||||
let mut loss = self.durability_loss(stack, amount);
|
||||
// The mods hear a server player's wear once Unbreaking has had its say,
|
||||
// and may change it or stop it.
|
||||
if loss > 0
|
||||
&& let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let mut points = loss as i32;
|
||||
let mut cancel = false;
|
||||
self.native_item_damaged(uuid, &name, &stack.item, &mut points, &mut cancel);
|
||||
loss = if cancel { 0 } else { points.max(0) as u32 };
|
||||
}
|
||||
if loss > 0 {
|
||||
self.trigger_item_durability(stack, stack.damage + loss);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! `Entity`: what every kind in the game's entity lists answers to, so a system
|
||||
//! can treat them alike and only write down where vanilla's kinds differ.
|
||||
//!
|
||||
//! Default methods follow vanilla's `Entity`; a kind overrides one only where
|
||||
//! its vanilla class does. [`EntityHandle`] names one entity by list and index,
|
||||
//! so a system can walk handles and reach each entity after other borrows of
|
||||
//! `Game` end. The walk allocates nothing and dispatch is static: hot paths pay
|
||||
//! no vtable. A new kind is one line in `entity_lists!` and one `impl Entity`.
|
||||
|
||||
use super::{FallingBlock, Game};
|
||||
use super::{
|
||||
boats::Boat, fireworks::Rocket, minecarts::Minecart, tnt::PrimedTnt, trident::ThrownTrident,
|
||||
};
|
||||
use horizon_client::entities::{ArmorStand, ExperienceOrb, ItemEntity};
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_client::projectiles::Projectile;
|
||||
use horizon_core::geometry::{Aabb, Direction, Vec3};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{level::data::PushReaction, piston_push};
|
||||
|
||||
pub(super) trait Entity {
|
||||
fn position(&self) -> Vec3;
|
||||
fn position_mut(&mut self) -> &mut Vec3;
|
||||
fn velocity_mut(&mut self) -> &mut Vec3;
|
||||
fn bounds(&self) -> Aabb;
|
||||
|
||||
fn on_ground(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `Entity.maxUpStep`.
|
||||
fn get_step_height(&self) -> f32 {
|
||||
0.0
|
||||
}
|
||||
fn is_alive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// `Entity.isSupportedBy`.
|
||||
fn is_supported_by(&self, _pos: BlockPos) -> bool {
|
||||
false
|
||||
}
|
||||
/// `Entity.getPistonPushReaction`.
|
||||
fn get_piston_push_reaction(&self) -> PushReaction {
|
||||
PushReaction::PushPull
|
||||
}
|
||||
/// `Entity.move(MoverType.PISTON, …)` once the movement is resolved.
|
||||
fn move_by_piston(&mut self, movement: Vec3) {
|
||||
let position = self.position_mut();
|
||||
*position = position.add_vector(movement);
|
||||
}
|
||||
/// `PistonMovingBlockEntity.causeBounce`: a slime block sets the velocity
|
||||
/// along the movement axis.
|
||||
fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
let velocity = self.velocity_mut();
|
||||
*velocity = piston_push::bounce_velocity(movement, *velocity);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields every kind stores under the same names.
|
||||
macro_rules! stored_motion {
|
||||
() => {
|
||||
fn position(&self) -> Vec3 {
|
||||
self.position
|
||||
}
|
||||
fn position_mut(&mut self) -> &mut Vec3 {
|
||||
&mut self.position
|
||||
}
|
||||
fn velocity_mut(&mut self) -> &mut Vec3 {
|
||||
&mut self.velocity
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// The game's lists of entities, in the order systems walk them. Hands them to
|
||||
/// `$then`, so one list drives the handles and every typed dispatch.
|
||||
macro_rules! entity_lists {
|
||||
($then:ident, $($args:tt)*) => {
|
||||
$crate::game::entity::$then! {
|
||||
($($args)*)
|
||||
Mob: mobs,
|
||||
Item: dropped,
|
||||
Boat: boats,
|
||||
Cart: carts,
|
||||
ArmorStand: armor_stands,
|
||||
Tnt: tnts,
|
||||
Falling: falling,
|
||||
Orb: orbs,
|
||||
Projectile: creatures.projectiles,
|
||||
Rocket: rockets,
|
||||
Trident: tridents,
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use entity_lists;
|
||||
|
||||
macro_rules! define_entity_handles {
|
||||
(() $($kind:ident: $($list:ident).+,)*) => {
|
||||
/// One entity, by list and index.
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
|
||||
pub(super) enum EntityHandle {
|
||||
$($kind(usize),)*
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Every entity's handle: lengths read now, nothing allocated, and
|
||||
/// no borrow of `Game` held, so the caller can change it mid-walk.
|
||||
pub(super) fn entity_handles(&self) -> impl Iterator<Item = EntityHandle> + use<> {
|
||||
std::iter::empty()
|
||||
$(.chain((0..self$(.$list)+.len()).map(EntityHandle::$kind)))*
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use define_entity_handles;
|
||||
|
||||
entity_lists!(define_entity_handles,);
|
||||
|
||||
macro_rules! entity_borrow {
|
||||
(shared, $place:expr) => {
|
||||
&$place
|
||||
};
|
||||
(exclusive, $place:expr) => {
|
||||
&mut $place
|
||||
};
|
||||
}
|
||||
pub(crate) use entity_borrow;
|
||||
|
||||
macro_rules! match_entity {
|
||||
(($borrow:ident, $entity:ident, $game:expr, $handle:expr, $body:expr)
|
||||
$($kind:ident: $($list:ident).+,)*) => {
|
||||
match $handle {
|
||||
$($crate::game::entity::EntityHandle::$kind(i) => {
|
||||
let $entity = $crate::game::entity::entity_borrow!($borrow, $game$(.$list)+[i]);
|
||||
$body
|
||||
})*
|
||||
}
|
||||
};
|
||||
}
|
||||
pub(crate) use match_entity;
|
||||
|
||||
/// Runs `$body` with `$entity` bound to the handle's entity, as its own type:
|
||||
/// one match, each arm compiled for its kind, so nothing goes through a vtable.
|
||||
macro_rules! with_entity {
|
||||
($game:expr, $handle:expr, $entity:ident => $body:expr) => {
|
||||
$crate::game::entity::entity_lists!(match_entity, shared, $entity, $game, $handle, $body)
|
||||
};
|
||||
}
|
||||
pub(super) use with_entity;
|
||||
|
||||
/// [`with_entity`], with the entity mutable.
|
||||
macro_rules! with_entity_mut {
|
||||
($game:expr, $handle:expr, $entity:ident => $body:expr) => {
|
||||
$crate::game::entity::entity_lists!(match_entity, exclusive, $entity, $game, $handle, $body)
|
||||
};
|
||||
}
|
||||
pub(super) use with_entity_mut;
|
||||
|
||||
impl Entity for Mob {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
Mob::bounds(self)
|
||||
}
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
fn get_step_height(&self) -> f32 {
|
||||
self.kind.step_height as f32
|
||||
}
|
||||
fn is_alive(&self) -> bool {
|
||||
!self.removed && self.alive()
|
||||
}
|
||||
fn is_supported_by(&self, pos: BlockPos) -> bool {
|
||||
self.supporting == Some([pos.x, pos.y, pos.z])
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ItemEntity {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
ItemEntity::bounds(self)
|
||||
}
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
fn move_by_piston(&mut self, movement: Vec3) {
|
||||
self.position = self.position.add_vector(movement);
|
||||
// Entity.move(MoverType.PISTON): onGround only if the push was down.
|
||||
if movement.y != 0.0 {
|
||||
self.on_ground = movement.y < 0.0;
|
||||
}
|
||||
}
|
||||
fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
ItemEntity::piston_slime_bounce(self, movement);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ExperienceOrb {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
ExperienceOrb::bounds(self)
|
||||
}
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
fn move_by_piston(&mut self, movement: Vec3) {
|
||||
self.position = self.position.add_vector(movement);
|
||||
if movement.y != 0.0 {
|
||||
self.on_ground = movement.y < 0.0;
|
||||
}
|
||||
}
|
||||
fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
|
||||
self.on_ground = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Boat {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
Boat::bounds(self)
|
||||
}
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Minecart {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
Minecart::bounds(self)
|
||||
}
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ArmorStand {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
ArmorStand::bounds(self)
|
||||
}
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
/// `ArmorStand.getPistonPushReaction`: a marker is ignored.
|
||||
fn get_piston_push_reaction(&self) -> PushReaction {
|
||||
if self.marker {
|
||||
PushReaction::IgnoreEntity
|
||||
} else {
|
||||
PushReaction::PushPull
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for PrimedTnt {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
PrimedTnt::bounds(self)
|
||||
}
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
|
||||
// Leave the ground so the next tick does not apply the on-ground
|
||||
// Y * -0.5 friction that cancels the launch.
|
||||
self.on_ground = false;
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for FallingBlock {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
horizon_client::falling_block::bounds(self.position)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Projectile {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
Projectile::bounds(self)
|
||||
}
|
||||
fn move_by_piston(&mut self, movement: Vec3) {
|
||||
self.position = self.position.add_vector(movement);
|
||||
self.needs_sync = true;
|
||||
}
|
||||
fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
|
||||
self.needs_sync = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for Rocket {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
Rocket::bounds(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for ThrownTrident {
|
||||
stored_motion!();
|
||||
fn bounds(&self) -> Aabb {
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - 0.25, p.y, p.z - 0.25),
|
||||
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,22 @@ impl Game<'_> {
|
||||
if &*stack.item != "minecraft:fishing_rod" {
|
||||
return false;
|
||||
}
|
||||
// The mods hear a server player's cast or reel-in first.
|
||||
if self.player.is_some()
|
||||
&& let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let state = match &self.hook {
|
||||
None => 0,
|
||||
Some(h) if matches!(h.state, State::Hooked(_)) => 4,
|
||||
Some(h) if h.nibble > 0 => 2,
|
||||
Some(_) => 3,
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_player_fish(uuid, &name, state, &mut cancel);
|
||||
if cancel {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
@@ -419,6 +435,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The mods hear a server player's fish bite, and may call it
|
||||
// off: the wait starts over.
|
||||
if let (Some(uuid), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let mut cancel = false;
|
||||
self.native_player_fish(uuid, &name, 1, &mut cancel);
|
||||
if cancel {
|
||||
h.time_until_lured = 0;
|
||||
h.time_until_hooked = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let pitch =
|
||||
1.0 + (self.world.random.next_float() - self.world.random.next_float()) * 0.4;
|
||||
self.audio.play(
|
||||
|
||||
@@ -205,6 +205,94 @@ impl Game<'_> {
|
||||
self.attack_ticks = 0;
|
||||
self.hand_strength.reset();
|
||||
}
|
||||
/// `Minecraft.tick`'s `pick`, then `handleKeybinds`' number keys: the
|
||||
/// tick's clicks act on what the item held before the keys picked (a
|
||||
/// spear's 4.5 blocks), with the item the keys select (attribute
|
||||
/// swapping a spear's reach onto a mace).
|
||||
pub(super) fn pick_tick_targets(&mut self) {
|
||||
self.tick_hit = Some(self.pick());
|
||||
self.tick_mob = None;
|
||||
let mob = match self.active_attack_range() {
|
||||
Some(range) => self.attack_range_pick(&range),
|
||||
None => None,
|
||||
};
|
||||
self.tick_mob = Some(mob.unwrap_or_else(|| self.mob_hit()));
|
||||
self.select_queued_hotbar();
|
||||
}
|
||||
/// `handleKeybinds`' number keys (`keyHotbarSlots[i].consumeClick`).
|
||||
pub(super) fn select_queued_hotbar(&mut self) {
|
||||
for slot in std::mem::take(&mut self.hotbar_presses) {
|
||||
self.selected = slot;
|
||||
}
|
||||
}
|
||||
/// The tick's clicks are done: later picks follow the crosshair again.
|
||||
pub(super) fn release_tick_targets(&mut self) {
|
||||
self.tick_hit = None;
|
||||
self.tick_mob = None;
|
||||
}
|
||||
/// `Minecraft.startAttack` on an entity: a main-hand item with an
|
||||
/// `attack_range` attacks only a hit inside it.
|
||||
pub(super) fn held_range_allows(&self, distance: f64) -> bool {
|
||||
let range = self.held().and_then(|stack| {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
});
|
||||
range.is_none_or(|r| attack_range_contains(&r, distance, self.creative))
|
||||
}
|
||||
/// `LocalPlayer.raycastHitResult`'s item (`getActiveItem`: the one in
|
||||
/// use, else the main hand's) `attack_range` component.
|
||||
fn active_attack_range(&self) -> Option<serde_json::Value> {
|
||||
let active = self
|
||||
.using
|
||||
.and_then(|(slot, _)| self.inventory.get(slot).and_then(Option::as_ref))
|
||||
.or_else(|| self.held())?;
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(active))
|
||||
.get("minecraft:attack_range")
|
||||
.cloned()
|
||||
}
|
||||
/// `AttackRange.getClosesetHit` against the mobs `mob_hit` can pick
|
||||
/// (`EntitySelector.CAN_BE_PICKED`), as `raycastHitResult` reads it: the
|
||||
/// nearest mob hit within the item's reach; no mob when the clip met a
|
||||
/// block within the block reach (`getHitEntitiesAlong`'s block result,
|
||||
/// kept by `filterHitResult`); `None` on a miss, where the plain pick
|
||||
/// decides.
|
||||
fn attack_range_pick(&self, range: &serde_json::Value) -> Option<Option<(usize, f64)>> {
|
||||
let eye = v3(self.eye_position()?);
|
||||
let look = v3(super::view_vector(self.yaw, self.pitch));
|
||||
let reach = attack_reach(range, self.creative);
|
||||
let camera = self.remote_camera_id();
|
||||
let boxes: Vec<(usize, Aabb)> = (self.mobs.iter().enumerate())
|
||||
.filter(|(_, m)| !m.removed && Some(m.id) != self.riding && Some(m.id) != camera)
|
||||
.map(|(i, m)| (i, m.bounds()))
|
||||
.collect();
|
||||
let clip = |from: Vec3, to: Vec3| {
|
||||
let length = from.distance_squared(to).sqrt();
|
||||
let d = to.subtract(from).scale(1.0 / length);
|
||||
(length > 0.0)
|
||||
.then(|| self.clip_ray([from.x, from.y, from.z], [d.x, d.y, d.z], length))
|
||||
.flatten()
|
||||
.map(|hit| Vec3::new(hit.point[0], hit.point[1], hit.point[2]))
|
||||
};
|
||||
let movement = self.known_player_movement();
|
||||
let nearest = super::spear::hits_in_reach(&boxes, eye, look, movement, reach, clip)
|
||||
.into_iter()
|
||||
.map(|(i, at)| (i, eye.distance_squared(at).sqrt()))
|
||||
.reduce(|a, b| if b.1 < a.1 { b } else { a });
|
||||
if nearest.is_some() {
|
||||
return Some(nearest);
|
||||
}
|
||||
let to = eye.add_vector(look.scale(f64::from(reach.1) + movement.dot(look).max(0.0)));
|
||||
clip(eye, to)
|
||||
.is_some_and(|block| eye.distance_squared(block) <= self.block_reach.powi(2))
|
||||
.then_some(None)
|
||||
}
|
||||
/// The attack and use part of `Minecraft.tick` for the local player:
|
||||
/// attack clicks since the last tick and the held attack button.
|
||||
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
|
||||
@@ -216,11 +304,10 @@ impl Game<'_> {
|
||||
// (scripts under --control hold buttons without grabbing it).
|
||||
let down = self.left;
|
||||
let ticks = self.clicks.destroying.ticks;
|
||||
// Minecraft.hitResult is picked once per frame: a creative break
|
||||
// does not move the use that follows it past the broken block.
|
||||
if use_clicks > 0 && attack_clicks > 0 {
|
||||
self.tick_hit = Some(self.pick());
|
||||
}
|
||||
// Minecraft.hitResult is picked once per tick, before the number
|
||||
// keys: a creative break does not move the use that follows it
|
||||
// past the broken block.
|
||||
self.pick_tick_targets();
|
||||
let mut world = GameHands {
|
||||
game: self,
|
||||
error: None,
|
||||
@@ -229,7 +316,7 @@ impl Game<'_> {
|
||||
let error = world.error.take();
|
||||
self.clicks = clicks;
|
||||
if let Some(e) = error {
|
||||
self.tick_hit = None;
|
||||
self.release_tick_targets();
|
||||
return Err(e);
|
||||
}
|
||||
// Use clicks follow the attack clicks within the tick, against
|
||||
@@ -245,7 +332,7 @@ impl Game<'_> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.tick_hit = None;
|
||||
self.release_tick_targets();
|
||||
used?;
|
||||
// A tick of breaking: the hit sound every fourth tick and the
|
||||
// cracking particles.
|
||||
@@ -267,8 +354,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Minecraft.tick: a screen keeps the attack from starting.
|
||||
// Minecraft.tick: a screen keeps the attack from starting, and
|
||||
// opening it released the keys (`KeyMapping.releaseAll`).
|
||||
self.clicks.miss_time = 10000;
|
||||
self.hotbar_presses.clear();
|
||||
}
|
||||
let d = &self.clicks.destroying;
|
||||
self.mining = d.active.then_some(Mining {
|
||||
@@ -385,7 +474,7 @@ impl Game<'_> {
|
||||
pub(super) fn tick_hand(&mut self) {
|
||||
// LocalPlayer.applyInput (the camera is the player's own).
|
||||
if self.player.is_some() {
|
||||
self.sway.tick(self.pitch, self.yaw);
|
||||
self.sway.tick_wrapped(self.pitch, self.yaw);
|
||||
}
|
||||
self.swing.tick();
|
||||
let main = self.inventory[self.selected].clone();
|
||||
|
||||
@@ -383,6 +383,15 @@ impl Game<'_> {
|
||||
let _ =
|
||||
m.apply_saved_attributes(g.saved.as_ref().and_then(|t| t.get("attributes")));
|
||||
}
|
||||
// ServerPlayer.restoreFrom: a body that follows another takes its
|
||||
// attributes (a scale of 0.1 stays 0.1 after a respawn or a trip
|
||||
// to another level).
|
||||
let g = &mut host.hub.guests[i];
|
||||
if let Some(carried) = g.carried_attributes.take()
|
||||
&& let Some(m) = g.motion.as_mut()
|
||||
{
|
||||
let _ = m.apply_saved_attributes(Some(&carried));
|
||||
}
|
||||
// The saved golden hearts go on the first body of a player who
|
||||
// has been here before.
|
||||
let saved = host.hub.guests[i].saved_absorption.take();
|
||||
@@ -2516,8 +2525,9 @@ impl Game<'_> {
|
||||
}
|
||||
let g = &mut host.hub.guests[gi];
|
||||
(g.yaw, g.pitch) = (yaw, pitch);
|
||||
// A new body there: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
// A new body there: no fall distance or motion carried over, but the
|
||||
// player's attributes (ServerPlayer.teleport keeps the entity).
|
||||
g.new_body(true);
|
||||
host.hub.teleport(gi, Some(at));
|
||||
}
|
||||
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
|
||||
@@ -3248,14 +3258,16 @@ impl Game<'_> {
|
||||
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
|
||||
g.loading = true;
|
||||
g.death_time = 0;
|
||||
g.health = 20.0;
|
||||
g.food = 20;
|
||||
g.saturation = 5.0;
|
||||
g.sent_health = None;
|
||||
if let Some(m) = g.motion.take() {
|
||||
// ServerPlayer.restoreFrom(old, false): the base values come back,
|
||||
// and the new player has the max health they give.
|
||||
if let Some(m) = g.new_body(false) {
|
||||
let m = *m;
|
||||
g.logs_kept = Some((m.hurt_log, m.attack_log));
|
||||
}
|
||||
g.health = g.respawn_health(self.motion);
|
||||
let mut xp = Experience {
|
||||
level: g.xp_level,
|
||||
progress: g.xp_progress,
|
||||
@@ -3847,6 +3859,12 @@ impl Game<'_> {
|
||||
w.player.fall_distance = 0.0;
|
||||
}
|
||||
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
|
||||
// The tick's whole movement, for the delta movement a glide's wall
|
||||
// hit reads (`handleMovePlayer` gives the server copy the client's
|
||||
// own movement).
|
||||
let tick_movement = travelled.iter().fold(Vec3::ZERO, |sum, d| {
|
||||
sum.add_vector(Vec3::new(d[0], d[1], d[2]))
|
||||
});
|
||||
// Entity.move's movementThisTick: each move, from where the
|
||||
// tick's first one started.
|
||||
let end = w.player.position;
|
||||
@@ -3957,6 +3975,7 @@ impl Game<'_> {
|
||||
let _ = player.peaceful_regeneration(rules);
|
||||
let _ = player.food_tick(health, rules);
|
||||
events.append(&mut player.events);
|
||||
player.carry_client_movement(tick_movement);
|
||||
let _ = MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
|
||||
@@ -130,6 +130,7 @@ impl Game<'_> {
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
let item_crafted = std::mem::take(&mut menu.item_crafted);
|
||||
menu.drops.clear();
|
||||
store_player(menu, &mut host.hub.guests[gi]);
|
||||
if !brewed.is_empty() {
|
||||
@@ -148,6 +149,15 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
if !item_crafted.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
let player = g.native_acting_id();
|
||||
for (recipe, stack) in &item_crafted {
|
||||
g.native_item_crafted(player, recipe, stack);
|
||||
}
|
||||
});
|
||||
}
|
||||
if !equipped.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
|
||||
@@ -520,8 +520,14 @@ impl Game<'_> {
|
||||
/// The end of `MinecraftServer.runServer`'s loop for a server thread: a
|
||||
/// finished `/tick sprint` reports to the operators as the server, then
|
||||
/// the next tick starts one tick length after this one, or straight away
|
||||
/// while the sprint runs (`checkShouldSprintThisTick`).
|
||||
pub(super) fn wait_for_next_server_tick(&mut self, next: &mut Instant, paused: bool) {
|
||||
/// while the sprint runs (`checkShouldSprintThisTick`). A server more
|
||||
/// than two seconds behind skips the ticks it missed: that returns how far
|
||||
/// behind it was, for the overload warning.
|
||||
pub(super) fn wait_for_next_server_tick(
|
||||
&mut self,
|
||||
next: &mut Instant,
|
||||
paused: bool,
|
||||
) -> Option<Duration> {
|
||||
if let Some(report) = self.clock.take_sprint_report() {
|
||||
// ServerTickRateManager.finishTickSprint: the server's sendSuccess.
|
||||
self.console_broadcast = Some(true);
|
||||
@@ -536,7 +542,7 @@ impl Game<'_> {
|
||||
let now = Instant::now();
|
||||
if !paused && self.clock.sprinting() {
|
||||
*next = now;
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
*next += self.clock.tick_duration();
|
||||
if *next > now {
|
||||
@@ -548,8 +554,11 @@ impl Game<'_> {
|
||||
std::thread::sleep(*next - now);
|
||||
}
|
||||
} else if now - *next > Duration::from_secs(2) {
|
||||
let behind = now - *next;
|
||||
*next = now;
|
||||
return Some(behind);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The levels this server tick runs, in `MinecraftServer.levels` order
|
||||
|
||||
@@ -477,12 +477,6 @@ impl Game<'_> {
|
||||
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
|
||||
&& p[1] >= at[1] - 3.0
|
||||
&& p[1] <= at[1] + 9.0
|
||||
};
|
||||
let struck: Vec<(u32, EntityView)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -575,12 +569,15 @@ impl Game<'_> {
|
||||
if !events.is_empty() {
|
||||
let _ = self.handle_mob_events(events);
|
||||
}
|
||||
// Cushion.thunderHit: a cushion in the box breaks and drops its item
|
||||
// Cushion.thunderHit: a cushion whose box meets the strike box breaks and drops its item
|
||||
// (the bolt is no player, so no creative spare). Last first, since
|
||||
// break_cushion removes the entry.
|
||||
for i in (0..self.cushions.len()).rev() {
|
||||
let p = self.cushions[i].position;
|
||||
if inside([p.x, p.y, p.z]) {
|
||||
let (lo, hi) = self.cushions[i].bounds();
|
||||
if strike.intersects(Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
)) {
|
||||
let before = self.dropped.len();
|
||||
self.break_cushion(i, false);
|
||||
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
|
||||
|
||||
@@ -536,8 +536,14 @@ impl Game<'_> {
|
||||
let rear = Vec3::new(rear[0], rear[1], rear[2])
|
||||
.rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32);
|
||||
at = Vec3::new(at.x + rear.x, at.y + rear.y, at.z + rear.z);
|
||||
// The tick a rider takes its seat is a teleport (`Entity.moveTo`), which
|
||||
// sets the old position too: the camera must not sweep from where the
|
||||
// player stood to the seat. The ticks after it move with the mount and
|
||||
// are interpolated as usual.
|
||||
let first = self.posed_rider != self.riding;
|
||||
self.posed_rider = self.riding;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
self.previous_position = if first { at } else { p.position };
|
||||
p.position = at;
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
|
||||
@@ -559,6 +559,11 @@ impl Game<'_> {
|
||||
.get(key)
|
||||
.map(String::as_str)
|
||||
}
|
||||
/// The block `state` is a state of: its name, as `GameLevel`'s helper of
|
||||
/// the same name gives it for the hooks that get a state in the world.
|
||||
pub(super) fn native_state_block(&self, state: BlockStateId) -> Option<&str> {
|
||||
self.pack.assets.registry.state(state).map(|s| &*s.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -869,7 +869,8 @@ impl GameLevel<'_> {
|
||||
/// the clicked position, whether the clicked block is being replaced, the
|
||||
/// item in hand) and `fluid` the fluid in the position the block would go
|
||||
/// in, which a waterlogged block reads. Build the state from `state` with
|
||||
/// `native_state_with_property`. It is asked for every placer that goes
|
||||
/// `native_state_with_property`, and know whether `state` is yours with
|
||||
/// `native_state_block`. It is asked for every placer that goes
|
||||
/// through vanilla's placement (a player's `BlockItem.place`, a
|
||||
/// dispenser's `DirectionalPlaceContext`), and the answer still takes its
|
||||
/// survival and entity obstruction checks. A trait mod returns
|
||||
@@ -1287,6 +1288,21 @@ impl Game<'_> {
|
||||
pub(super) fn native_frame(&mut self, elapsed: Duration) {
|
||||
mod_api::dispatch_frame(self, mod_api::mods(), elapsed);
|
||||
}
|
||||
/// The frame rate this window is about to be held to: Max Framerate after the
|
||||
/// inactivity throttle and Battery Mode (260 is unlimited). `focused` is whether the
|
||||
/// window has focus, `minimized` whether it is minimised and `in_world` whether a world
|
||||
/// is open. A mod may lower `limit`, as Dynamic FPS does for a window in the background:
|
||||
/// Horizon keeps the lower of its own limit and the mod's, and ignores values below 1.
|
||||
/// API @API:framerate_limit@.
|
||||
pub(super) fn native_framerate_limit(
|
||||
&self,
|
||||
focused: bool,
|
||||
minimized: bool,
|
||||
in_world: bool,
|
||||
limit: &mut i32,
|
||||
) {
|
||||
let _ = (focused, minimized, in_world, limit);
|
||||
}
|
||||
/// Render-thread notification after a resource-pack reload has applied successfully.
|
||||
/// Atlas, models, renderer and client caches already refer to the new resources.
|
||||
/// Selection/F3+T and server-pack reloads share this point; initial startup does not.
|
||||
@@ -2717,6 +2733,28 @@ impl Game<'_> {
|
||||
pub(super) fn native_perform_console_command(&mut self, command: &str, ran: &mut bool) {
|
||||
let _ = (command, ran);
|
||||
}
|
||||
/// A server player's fishing rod, with `state`: 0 the cast
|
||||
/// (`FishingRodItem.use` throwing the bobber), 1 a fish biting
|
||||
/// (`FishingHook.catchingFish`), 2 the reel-in of a bite, which gives the
|
||||
/// catch, 3 the reel-in with nothing on the line, 4 the reel-in of a
|
||||
/// bobber hooked to an entity or item (like Bukkit's `PlayerFishEvent`;
|
||||
/// NeoForge's `ItemFishedEvent` is later, in `FishingHook.retrieve`). Set `cancel` on the cast and nothing
|
||||
/// is thrown; on a bite the fish gets away and the wait starts over; on
|
||||
/// a reel-in (2 to 4) nothing happens, the bobber stays out and the rod
|
||||
/// takes no wear. The bobber ending by itself (too far, the rod put
|
||||
/// away, the owner dying), nor the host of a LAN world's rod. The
|
||||
/// player's state is swapped in, so `native_acting_player`, `native_tell`
|
||||
/// and the item helpers reach them. Once for each cast, bite and reel-in,
|
||||
/// on a server. @API:native_player_fish@.
|
||||
pub(super) fn native_player_fish(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
state: i32,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
let _ = (player, name, state, cancel);
|
||||
}
|
||||
/// A click in a menu a mod opened (`native_open_menu`), which moved
|
||||
/// nothing: the menu's `slot` (its own first, then the player's
|
||||
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
|
||||
@@ -3115,6 +3153,16 @@ impl Game<'_> {
|
||||
pub(super) fn native_level_unload(&mut self, dimension: &str) {
|
||||
let _ = dimension;
|
||||
}
|
||||
/// The server's difficulty changed, from `from` to `to` (`peaceful`,
|
||||
/// `easy`, `normal`, `hard`; NeoForge's `DifficultyChangeEvent`). It is
|
||||
/// vanilla's `MinecraftServer.setDifficulty`, which `/difficulty`, a
|
||||
/// dedicated server's management and a test environment's difficulty all
|
||||
/// go through, and it is told only when the difficulty is another one. It
|
||||
/// only hears: the change has already happened. Server-side. API
|
||||
/// @API:difficulty_changed@.
|
||||
pub(super) fn native_difficulty_changed(&mut self, from: &str, to: &str) {
|
||||
let _ = (from, to);
|
||||
}
|
||||
/// Chunk `chunk` (`[x, z]`, in chunks) of `dimension` came into the world
|
||||
/// (NeoForge's `ChunkEvent.Load`): `new` when it was just generated, false
|
||||
/// when read from the save. A flat world's chunk that leaves before it's
|
||||
@@ -3494,6 +3542,27 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_loot_drops(self, mod_api::mods(), context, drops);
|
||||
}
|
||||
/// The item `item` of a server player is about to lose `amount` points of
|
||||
/// durability (`ItemStack.hurtAndBreak`; Bukkit's `PlayerItemDamageEvent`):
|
||||
/// the tool that mines or hits, the armour that takes a hit, a shield, a
|
||||
/// rod, an elytra. `amount` is what is left after Unbreaking; change it
|
||||
/// (0 or less leaves the item whole) or set `cancel` for no wear at all. It
|
||||
/// is not heard for a creative player's tools (they take no wear) nor when
|
||||
/// Unbreaking already saved the item, nor for the wear of a mob's armour or
|
||||
/// of a tool a dispenser uses, nor the host of a LAN world. The player's
|
||||
/// state is swapped in, so `native_acting_player`, `native_tell` and the
|
||||
/// item helpers reach them. Once for each wear, on a server.
|
||||
/// @API:native_item_damaged@.
|
||||
pub(super) fn native_item_damaged(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
item: &str,
|
||||
amount: &mut i32,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
let _ = (player, name, item, amount, cancel);
|
||||
}
|
||||
/// `player` threw `count` of `item` (`Player.drop`: the drop key, a
|
||||
/// menu's throw...), not what scatters when they die. It only hears.
|
||||
/// Server-side. API 40.
|
||||
@@ -3512,6 +3581,17 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_item_picked_up(self, mod_api::mods(), player, item, count, canceled);
|
||||
}
|
||||
/// A player took a crafting grid's result (`ResultSlot.onTake`, NeoForge's
|
||||
/// `PlayerEvent.ItemCraftedEvent`): `player` is the player's uuid, `recipe`
|
||||
/// the recipe the grid matched (`minecraft:oak_planks`) and `stack` the
|
||||
/// result it took, with its components. It only hears: the take has
|
||||
/// already happened. It fires for the crafting grid of an inventory or a
|
||||
/// crafting table, on the integrated server and a dedicated server, guests
|
||||
/// included, and not for a stonecutter's, a smithing table's or a
|
||||
/// furnace's result. Server-side. API @API:item_crafted@.
|
||||
pub(super) fn native_item_crafted(&mut self, player: u128, recipe: &str, stack: &ItemStack) {
|
||||
let _ = (player, recipe, stack);
|
||||
}
|
||||
/// Whether a mod hears `native_experience_orb_pickup`: set `listened`
|
||||
/// (a HEAD injection), or the server skips swapping the player in for it,
|
||||
/// and with this empty the pickup costs nothing more without mods.
|
||||
@@ -4190,6 +4270,14 @@ impl GameLevel<'_> {
|
||||
) -> Option<BlockStateId> {
|
||||
with_property(self.registry, state, key, value)
|
||||
}
|
||||
/// The block `state` is a state of (`BlockState.getBlock()`): its name,
|
||||
/// `mymod:cable`. `native_state_for_placement` is asked for every block
|
||||
/// placed and given the block's default state: compare this with your
|
||||
/// block to answer only for it. None for an unknown state. API
|
||||
/// @API:state_block@.
|
||||
pub(super) fn native_state_block(&self, state: BlockStateId) -> Option<&str> {
|
||||
self.registry.state(state).map(|s| &*s.name)
|
||||
}
|
||||
/// This level's own random (vanilla's `Level.random`): the stream
|
||||
/// `native_level_random(dimension)` hands a server hook, for a world hook
|
||||
/// that draws as a vanilla block does (`isBonemealSuccess`), so the mod's
|
||||
@@ -4807,6 +4895,12 @@ impl Game<'_> {
|
||||
) -> Option<ItemStack> {
|
||||
self.insert_stack_through(pos, face, stack)
|
||||
}
|
||||
/// The window's width and height in physical pixels, independent of GUI
|
||||
/// scale; none on a server or when no window exists. API @API:physical_window_size@.
|
||||
pub(super) fn native_physical_window_size(&self) -> Option<[u32; 2]> {
|
||||
let size = self.window.as_ref()?.inner_size();
|
||||
Some([size.width, size.height])
|
||||
}
|
||||
/// Where the mouse is, in GUI pixels. API 20.
|
||||
pub(super) fn native_cursor(&self) -> [f32; 2] {
|
||||
[self.cursor.0, self.cursor.1]
|
||||
@@ -6357,7 +6451,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn native_breathing_stays_hookable() {
|
||||
let source = include_str!("../breathing.rs");
|
||||
let source = include_str!("../breathing.rs").replace("\r\n", "\n");
|
||||
let hook = source.find("pub fn native_living_breathe(").unwrap();
|
||||
// From its doc comment's first line, so an attribute above it counts.
|
||||
let start = source[..hook].find("/// An entity's breathing").unwrap();
|
||||
@@ -6373,7 +6467,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn native_loot_kinds_stay_hookable() {
|
||||
let source = include_str!("../../../horizon-world/src/loot.rs");
|
||||
let source = include_str!("../../../horizon-world/src/loot.rs").replace("\r\n", "\n");
|
||||
for hook in [
|
||||
"pub fn native_loot_function(",
|
||||
"pub fn native_loot_condition(",
|
||||
@@ -6392,6 +6486,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ingredient_stays_hookable() {
|
||||
let source = include_str!("../crafting.rs");
|
||||
let at = source.find("pub fn native_ingredient(").unwrap();
|
||||
let start = source[..at].rfind("///").unwrap();
|
||||
let body = &source[start..start + source[start..].find("\n}\n").unwrap()];
|
||||
for line in body.lines() {
|
||||
assert!(
|
||||
!line.contains("cfg"),
|
||||
"a cfg in the ingredient hook: {line}"
|
||||
);
|
||||
for mac in ["!(", "![", "! {", "!{"] {
|
||||
assert!(!line.contains(mac), "a macro: {line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_table_load_stays_hookable() {
|
||||
let source = include_str!("../../../horizon-world/src/loot.rs").replace("\r\n", "\n");
|
||||
@@ -6494,6 +6605,8 @@ mod tests {
|
||||
let menu_hooks = free_hooks(include_str!("../menu.rs"));
|
||||
// The air supply's (`horizon_client::breathing`).
|
||||
let breath_hooks = free_hooks(include_str!("../breathing.rs"));
|
||||
// The crafting loaders' (`horizon_client_lib::crafting`).
|
||||
let crafting_hooks = free_hooks(include_str!("../crafting.rs"));
|
||||
let declared: BTreeSet<_> = section
|
||||
.lines()
|
||||
.chain(level_section.lines())
|
||||
@@ -6508,6 +6621,7 @@ mod tests {
|
||||
.chain(loot_hooks.iter().copied())
|
||||
.chain(menu_hooks.iter().copied())
|
||||
.chain(breath_hooks.iter().copied())
|
||||
.chain(crafting_hooks.iter().copied())
|
||||
.collect();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut contracts = BTreeSet::new();
|
||||
@@ -6525,6 +6639,10 @@ mod tests {
|
||||
format!("horizon_world::item_defaults::native_{name}")
|
||||
} else if loot_hooks.contains(name) {
|
||||
format!("horizon_world::loot::native_{name}")
|
||||
} else if crafting_hooks.contains(name) {
|
||||
// crafting.rs compiles into horizon-client-lib (#5446's
|
||||
// split): horizon-mixins resolves the crate by its package.
|
||||
format!("horizon_client_lib::crafting::native_{name}")
|
||||
} else if menu_hooks.contains(name) {
|
||||
format!("horizon_client::menu::native_{name}")
|
||||
} else if breath_hooks.contains(name) {
|
||||
|
||||
@@ -390,6 +390,18 @@ impl Game<'_> {
|
||||
host.hub.send_to(g.conn, &packet);
|
||||
}
|
||||
}
|
||||
/// Posts a line to the dedicated server's event stream (`GET /api/v1/events`)
|
||||
/// as an `announce` event, which horizon-server's Discord bridge relays when
|
||||
/// `[discord] events` lists `announce`: `name` and `uuid` say who it is
|
||||
/// about (empty and 0 for no one), `message` is the line (`§` codes colour it).
|
||||
/// Only on a dedicated server; false elsewhere and for an empty message.
|
||||
pub(super) fn native_server_event(&self, name: &str, uuid: u128, message: &str) -> bool {
|
||||
if self.options.dedicated.is_none() || message.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
super::dedicated::events::record("announce", name, uuid, json!({ "message": message }));
|
||||
true
|
||||
}
|
||||
/// A line of the command's answer, inside `native_perform_command`:
|
||||
/// sent to the player whose command it is after the call, as vanilla
|
||||
/// commands' feedback is.
|
||||
|
||||
@@ -16,8 +16,16 @@ impl Game<'_> {
|
||||
camera: glam::DVec3,
|
||||
lines: &mut Vec<gizmos::Line>,
|
||||
) -> Vec<dynamic_light::Colored> {
|
||||
let particle_lights = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.and_then(|r| r.particle_lights())
|
||||
.map_or_else(Vec::new, |entries| {
|
||||
self.particles
|
||||
.lights(entries, self.clock.camera_fraction() as f32)
|
||||
});
|
||||
let Some(player) = self.native_player() else {
|
||||
return Vec::new();
|
||||
return dynamic_light::select_colored(particle_lights.into_iter(), camera);
|
||||
};
|
||||
let mut rims = Vec::new();
|
||||
let mut lights = Vec::new();
|
||||
@@ -98,7 +106,9 @@ impl Game<'_> {
|
||||
))
|
||||
});
|
||||
dynamic_light::select_colored(
|
||||
sources.map(|l| (l.at, l.rgb, l.radius, l.intensity)),
|
||||
sources
|
||||
.map(|l| (l.at, l.rgb, l.radius, l.intensity))
|
||||
.chain(particle_lights),
|
||||
camera,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -734,6 +734,16 @@ impl Game<'_> {
|
||||
ids.push((o.id, true));
|
||||
}
|
||||
}
|
||||
for t in &self.tridents {
|
||||
if tracked.contains_key(&t.id) {
|
||||
ids.push((t.id, true));
|
||||
}
|
||||
}
|
||||
for r in &self.rockets {
|
||||
if tracked.contains_key(&r.id) && r.attached_to.is_none() && !r.attached {
|
||||
ids.push((r.id, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
@@ -921,6 +931,30 @@ impl Game<'_> {
|
||||
inside.push((p.id, pos));
|
||||
}
|
||||
}
|
||||
// A trident (0.5 across) or rocket (0.25) in flight, or one that moved
|
||||
// this tick; a rocket that carries its owner stays.
|
||||
let mut flyers: Vec<(u32, Vec3, Vec3, f32)> = Vec::new();
|
||||
for t in self
|
||||
.tridents
|
||||
.iter_mut()
|
||||
.filter(|t| !t.in_ground || t.previous != t.position)
|
||||
{
|
||||
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
|
||||
flyers.push((id, t.previous, t.position, 0.5));
|
||||
}
|
||||
for r in self
|
||||
.rockets
|
||||
.iter_mut()
|
||||
.filter(|r| r.attached_to.is_none() && !r.attached)
|
||||
{
|
||||
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
|
||||
flyers.push((id, r.previous, r.position, 0.25));
|
||||
}
|
||||
for (id, from, to, size) in flyers {
|
||||
if let Some(pos) = self.swept_portal(from, to, size, size, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
}
|
||||
}
|
||||
self.portals.sections = portal_sections;
|
||||
for (id, pos) in inside {
|
||||
self.portals
|
||||
@@ -1058,6 +1092,28 @@ impl Game<'_> {
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.tridents.iter().position(|t| t.id == id) {
|
||||
let plan = self.entity_plan(entry, self.tridents[i].position, 0.5, 0.5, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.tridents[i].to_nbt(uuid, self.persist_raw(id));
|
||||
self.tridents.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.rockets.iter().position(|r| r.id == id) {
|
||||
let plan = self.entity_plan(entry, self.rockets[i].position, 0.25, 0.25, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
return;
|
||||
}
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist_raw(id));
|
||||
self.rockets.remove(i);
|
||||
self.forget_entity(id);
|
||||
self.portals.arrivals.push(Arrival { plan, tag });
|
||||
self.portals.dirty = true;
|
||||
} else if let Some(i) = self.orbs.iter().position(|o| o.id == id) {
|
||||
let plan = self.entity_plan(entry, self.orbs[i].position, 0.5, 0.5, false);
|
||||
if !self.may_enter(plan.dim) {
|
||||
|
||||
@@ -574,9 +574,23 @@ impl Game<'_> {
|
||||
has_overlay: false,
|
||||
};
|
||||
let limit = framerate_limit(self.settings.max_fps as i32, context);
|
||||
self.settings
|
||||
let limit = self
|
||||
.settings
|
||||
.horizon
|
||||
.battery_limit(limit, self.title.is_none(), self.focused)
|
||||
.battery_limit(limit, self.title.is_none(), self.focused);
|
||||
// A native mod may only lower it (`native_framerate_limit`).
|
||||
let mut hooked = limit;
|
||||
self.native_framerate_limit(
|
||||
self.focused,
|
||||
context.iconified,
|
||||
self.title.is_none(),
|
||||
&mut hooked,
|
||||
);
|
||||
if hooked >= 1 {
|
||||
hooked.min(limit)
|
||||
} else {
|
||||
limit
|
||||
}
|
||||
}
|
||||
/// `Minecraft.runTick`'s frame limiter: `FramerateLimiter.limitDisplayFPS`
|
||||
/// below 260 frames per second, sleeping then spin-waiting until the
|
||||
|
||||
@@ -299,6 +299,7 @@ impl Game<'_> {
|
||||
pack,
|
||||
textures,
|
||||
cem,
|
||||
block_entity_shapes,
|
||||
} = staged;
|
||||
// Keep the working GPU resources, and hand its window surface over.
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
@@ -372,6 +373,9 @@ impl Game<'_> {
|
||||
.cem
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
|
||||
self.res
|
||||
.block_entity_render()
|
||||
.set_shapes(block_entity_shapes);
|
||||
// The random entity textures and emissive overlays the new atlas packs. A new scan is
|
||||
// a new generation:
|
||||
// every mob (every splitscreen player's too) chooses again on its next tick.
|
||||
@@ -479,6 +483,7 @@ pub(super) fn stage_pack(
|
||||
&root,
|
||||
res.mob_types(),
|
||||
));
|
||||
let block_entity_shapes = res.block_entity_render().shapes_for(&root);
|
||||
progress.set(3);
|
||||
Ok(StagedPack {
|
||||
root,
|
||||
@@ -486,6 +491,7 @@ pub(super) fn stage_pack(
|
||||
pack,
|
||||
textures,
|
||||
cem,
|
||||
block_entity_shapes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -498,6 +504,8 @@ pub(super) struct StagedPack {
|
||||
pub textures: PreparedTextures,
|
||||
/// The packs' custom entity models (OptiFine CEM).
|
||||
pub cem: Arc<horizon_client::mobs::cem::CemModels>,
|
||||
/// The block entities' models, the packs' custom entity models over vanilla's.
|
||||
pub block_entity_shapes: Arc<horizon_client::block_entity_render::BlockEntityShapes>,
|
||||
}
|
||||
|
||||
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
|
||||
|
||||
@@ -493,8 +493,11 @@ impl Game<'_> {
|
||||
}
|
||||
"minecraft:trident" => {
|
||||
if let Some(n) = entity_nbt::trident_from_nbt(&tag) {
|
||||
self.tridents
|
||||
.push(super::trident::ThrownTrident::from_nbt(n));
|
||||
let mut t = super::trident::ThrownTrident::from_nbt(n);
|
||||
// One that came through a portal keeps its cooldown.
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
|
||||
self.restore_portal_cooldown(t.id, &tag);
|
||||
self.tridents.push(t);
|
||||
}
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
@@ -503,6 +506,8 @@ impl Game<'_> {
|
||||
let id = self.next_entity;
|
||||
let mut rocket = super::fireworks::Rocket::from_nbt(r);
|
||||
rocket.id = id;
|
||||
// One that came through a portal keeps its cooldown.
|
||||
self.restore_portal_cooldown(id, &tag);
|
||||
self.rockets.push(rocket);
|
||||
self.remember(id, uuid, tag);
|
||||
}
|
||||
@@ -1085,11 +1090,17 @@ impl Game<'_> {
|
||||
}
|
||||
/// An entity Horizon keeps no compound for (orbs, projectiles, the
|
||||
/// crystals and the dragon) still keeps its `Tags`, by entity id, so
|
||||
/// `@e[tag=]`, `/tag` and `/data get` see them.
|
||||
/// `@e[tag=]`, `/tag` and `/data get` see them, and the `Item` a
|
||||
/// fireball or a thrown item projectile was loaded with.
|
||||
pub(super) fn remember_tags(&mut self, id: u32, tag: &Tag) {
|
||||
let mut map = horizon_world::nbt::Compound::new();
|
||||
if let Some(tags @ Tag::List(_)) = tag.get("Tags") {
|
||||
let mut map = horizon_world::nbt::Compound::new();
|
||||
map.insert("Tags".into(), tags.clone());
|
||||
}
|
||||
if let Some(item @ Tag::Compound(_)) = tag.get("Item") {
|
||||
map.insert("Item".into(), item.clone());
|
||||
}
|
||||
if !map.is_empty() {
|
||||
self.persist.raw.insert(id, Tag::Compound(map));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +427,7 @@ impl Game<'_> {
|
||||
let visually_swimming =
|
||||
p.pose == Pose::Swimming || (!p.fall_flying && p.pose == Pose::FallFlying);
|
||||
let in_water = p.fluids.was_touching_water;
|
||||
let in_lava = p.is_in_lava();
|
||||
let (yaw, pitch) = (self.yaw, self.pitch);
|
||||
let main = self.inventory[self.selected].clone();
|
||||
let off = self.offhand.as_ref().map(|s| s.item.clone());
|
||||
@@ -497,6 +498,9 @@ impl Game<'_> {
|
||||
m.extra.crouching = crouching;
|
||||
// Guests and server players take their pose from DATA_POSE, not here.
|
||||
m.extra.avatar_pose = pose;
|
||||
// What custom entity model animations read of the fluids (`is_in_water`, crawling).
|
||||
m.fluids.in_water = in_water;
|
||||
m.fluids.in_lava = in_lava;
|
||||
m.extra.passenger = riding;
|
||||
m.extra.previous_velocity = m.velocity;
|
||||
m.velocity = velocity;
|
||||
|
||||
@@ -9,13 +9,17 @@
|
||||
//! then run through the block collision scan, so a pushed entity stops at a
|
||||
//! wall instead of passing through it.
|
||||
//!
|
||||
use super::Game;
|
||||
use super::{
|
||||
Game,
|
||||
entity::{Entity, EntityHandle, with_entity, with_entity_mut},
|
||||
};
|
||||
use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip};
|
||||
use horizon_core::geometry::{Aabb, Axis, Direction, ShapeOp, Vec3, border::BorderGeometry};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::{
|
||||
block_collision_scan::BlockCollisionWorld, level::pistons::MovingPiston,
|
||||
piston_movement::PistonMovement, piston_push, world_movement::EntityMovementWorld,
|
||||
block_collision_scan::BlockCollisionWorld, level::data::PushReaction::IgnoreEntity,
|
||||
level::pistons::MovingPiston, piston_movement::PistonMovement, piston_push,
|
||||
world_movement::EntityMovementWorld,
|
||||
};
|
||||
|
||||
/// What one piston asks for this tick: where it is, its state, and the progress
|
||||
@@ -33,20 +37,7 @@ pub(super) type ShulkerPush = ([i32; 3], u8, [f64; 6]);
|
||||
enum Target {
|
||||
Player,
|
||||
Guest(u32),
|
||||
Mob(usize),
|
||||
Item(usize),
|
||||
Boat(usize),
|
||||
Cart(usize),
|
||||
ArmorStand(usize),
|
||||
/// Primed TNT (`PrimedTnt`): slime piston bounce (#4550).
|
||||
Tnt(usize),
|
||||
/// Every other entity `getEntities` returns is pushed too: only
|
||||
/// `PushReaction.IGNORE_ENTITY` (a marker armor stand) is spared.
|
||||
Falling(usize),
|
||||
Orb(usize),
|
||||
Projectile(usize),
|
||||
Rocket(usize),
|
||||
Trident(usize),
|
||||
Entity(EntityHandle),
|
||||
}
|
||||
|
||||
/// `Entity.getStepHeight` of each body, read before the push.
|
||||
@@ -103,12 +94,12 @@ impl Game<'_> {
|
||||
Vec3::new(lid[3], lid[4], lid[5]),
|
||||
);
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
for target in self.push_targets() {
|
||||
self.push_targets().for_each(|target| {
|
||||
let Some(body) = self.push_body(target, at, steps) else {
|
||||
continue;
|
||||
return;
|
||||
};
|
||||
if !body.bounds.intersects(area) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
let Ok(moved) = pushed_movement(
|
||||
SceneCollisions {
|
||||
@@ -123,12 +114,12 @@ impl Game<'_> {
|
||||
body.step_height,
|
||||
body.on_ground,
|
||||
) else {
|
||||
continue;
|
||||
return;
|
||||
};
|
||||
if moved.length_squared() > 0.0 {
|
||||
self.apply_push(target, moved);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn push_for_piston(
|
||||
@@ -189,10 +180,11 @@ impl Game<'_> {
|
||||
delta_progress,
|
||||
)
|
||||
});
|
||||
let targets = self.push_targets();
|
||||
for &target in &targets {
|
||||
// `for_each`: a chain folds list by list, where `next` would re-check every
|
||||
// link for each entity.
|
||||
self.push_targets().for_each(|target| {
|
||||
let Some(body) = self.push_body(target, pos, &steps) else {
|
||||
continue;
|
||||
return;
|
||||
};
|
||||
let amount = piston_push::collided_amount(
|
||||
&boxes,
|
||||
@@ -243,11 +235,11 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Vanilla selects honey passengers after all collided entities have moved.
|
||||
if honey && movement.normal()[1] == 0 {
|
||||
let area = piston_push::sticky_area(pos, piston.shape_state(), sticky_top);
|
||||
for target in targets {
|
||||
for target in self.push_targets() {
|
||||
let Some(body) = self.push_body(target, pos, &steps) else {
|
||||
continue;
|
||||
};
|
||||
@@ -347,38 +339,26 @@ impl Game<'_> {
|
||||
|
||||
/// The entities a moving block can run into: the window's player, the LAN
|
||||
/// guests in the level, then the mobs.
|
||||
fn push_targets(&self) -> Vec<Target> {
|
||||
let mut targets = Vec::new();
|
||||
if self.player.is_some() {
|
||||
targets.push(Target::Player);
|
||||
}
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
targets.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| Target::Guest(g.conn)),
|
||||
);
|
||||
}
|
||||
targets.extend((0..self.mobs.len()).map(Target::Mob));
|
||||
targets.extend((0..self.dropped.len()).map(Target::Item));
|
||||
targets.extend((0..self.boats.len()).map(Target::Boat));
|
||||
targets.extend((0..self.carts.len()).map(Target::Cart));
|
||||
targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand));
|
||||
targets.extend((0..self.tnts.len()).map(Target::Tnt));
|
||||
targets.extend((0..self.falling.len()).map(Target::Falling));
|
||||
targets.extend((0..self.orbs.len()).map(Target::Orb));
|
||||
targets.extend((0..self.creatures.projectiles.len()).map(Target::Projectile));
|
||||
targets.extend((0..self.rockets.len()).map(Target::Rocket));
|
||||
targets.extend((0..self.tridents.len()).map(Target::Trident));
|
||||
targets
|
||||
fn push_targets(&self) -> impl Iterator<Item = Target> + use<> {
|
||||
let player = self.player.is_some().then_some(Target::Player);
|
||||
// Empty, and so unallocated, unless guests are here.
|
||||
let guests: Vec<Target> = self.lan.as_ref().map_or_else(Vec::new, |host| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
|
||||
.map(|g| Target::Guest(g.conn))
|
||||
.collect()
|
||||
});
|
||||
player
|
||||
.into_iter()
|
||||
.chain(guests)
|
||||
.chain(self.entity_handles().map(Target::Entity))
|
||||
}
|
||||
|
||||
/// What the push needs from one entity, or `None` when it is not pushed:
|
||||
/// removed, dead, a spectator, or an armor-stand marker.
|
||||
fn push_body(&self, target: Target, pos: BlockPos, steps: &StepHeights) -> Option<Body> {
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
match target {
|
||||
Target::Player => {
|
||||
let p = self.player.as_ref()?;
|
||||
@@ -414,133 +394,18 @@ impl Game<'_> {
|
||||
.is_some_and(|m| m.ground.supporting_block == Some(pos)),
|
||||
})
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &self.mobs[i];
|
||||
if m.removed || !m.alive() {
|
||||
Target::Entity(handle) => with_entity!(self, handle, entity => {
|
||||
if !entity.is_alive() || entity.get_piston_push_reaction() == IgnoreEntity {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: m.bounds(),
|
||||
position: m.position,
|
||||
on_ground: m.on_ground,
|
||||
step_height: m.kind.step_height as f32,
|
||||
supported: m.supporting == Some(at),
|
||||
bounds: Entity::bounds(entity),
|
||||
position: entity.position(),
|
||||
on_ground: entity.on_ground(),
|
||||
step_height: entity.get_step_height(),
|
||||
supported: entity.is_supported_by(pos),
|
||||
})
|
||||
}
|
||||
Target::Item(i) => {
|
||||
let entity = &self.dropped[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Boat(i) => {
|
||||
let entity = &self.boats[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Cart(i) => {
|
||||
let entity = &self.carts[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::ArmorStand(i) => {
|
||||
let entity = &self.armor_stands[i];
|
||||
if entity.marker {
|
||||
return None;
|
||||
}
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Tnt(i) => {
|
||||
let entity = &self.tnts[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Falling(i) => {
|
||||
let entity = &self.falling[i];
|
||||
Some(Body {
|
||||
bounds: horizon_client::falling_block::bounds(entity.position),
|
||||
position: entity.position,
|
||||
on_ground: false,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Orb(i) => {
|
||||
let entity = &self.orbs[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: entity.on_ground,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Projectile(i) => {
|
||||
let entity = &self.creatures.projectiles[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: false,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Rocket(i) => {
|
||||
let entity = &self.rockets[i];
|
||||
Some(Body {
|
||||
bounds: entity.bounds(),
|
||||
position: entity.position,
|
||||
on_ground: false,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
Target::Trident(i) => {
|
||||
let entity = &self.tridents[i];
|
||||
Some(Body {
|
||||
bounds: Aabb::new(
|
||||
Vec3::new(
|
||||
entity.position.x - 0.25,
|
||||
entity.position.y,
|
||||
entity.position.z - 0.25,
|
||||
),
|
||||
Vec3::new(
|
||||
entity.position.x + 0.25,
|
||||
entity.position.y + 0.5,
|
||||
entity.position.z + 0.25,
|
||||
),
|
||||
),
|
||||
position: entity.position,
|
||||
on_ground: false,
|
||||
step_height: 0.0,
|
||||
supported: false,
|
||||
})
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,7 +448,7 @@ impl Game<'_> {
|
||||
// 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 {
|
||||
if let Target::Entity(EntityHandle::ArmorStand(i)) = target {
|
||||
self.armor_stands[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
|
||||
}
|
||||
if resolved.length_squared() <= 1.0e-7
|
||||
@@ -614,7 +479,7 @@ impl Game<'_> {
|
||||
let g = host.hub.guests.iter_mut().find(|g| g.conn == conn)?;
|
||||
limit(&mut g.motion.as_deref_mut()?.piston)
|
||||
}
|
||||
Target::Mob(i) => limit(&mut self.mobs[i].piston),
|
||||
Target::Entity(EntityHandle::Mob(i)) => limit(&mut self.mobs[i].piston),
|
||||
_ => limit(accumulators.entry(target).or_default()),
|
||||
}
|
||||
}
|
||||
@@ -640,44 +505,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &mut self.mobs[i];
|
||||
m.position = m.position.add_vector(movement);
|
||||
}
|
||||
Target::Item(i) => {
|
||||
let item = &mut self.dropped[i];
|
||||
item.position = item.position.add_vector(movement);
|
||||
// Entity.move(MoverType.PISTON): onGround only if the push was down.
|
||||
if movement.y != 0.0 {
|
||||
item.on_ground = movement.y < 0.0;
|
||||
}
|
||||
}
|
||||
Target::Boat(i) => self.boats[i].position = self.boats[i].position.add_vector(movement),
|
||||
Target::Cart(i) => self.carts[i].position = self.carts[i].position.add_vector(movement),
|
||||
Target::ArmorStand(i) => {
|
||||
self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement)
|
||||
}
|
||||
Target::Tnt(i) => self.tnts[i].position = self.tnts[i].position.add_vector(movement),
|
||||
Target::Falling(i) => {
|
||||
self.falling[i].position = self.falling[i].position.add_vector(movement)
|
||||
}
|
||||
Target::Orb(i) => {
|
||||
let orb = &mut self.orbs[i];
|
||||
orb.position = orb.position.add_vector(movement);
|
||||
if movement.y != 0.0 {
|
||||
orb.on_ground = movement.y < 0.0;
|
||||
}
|
||||
}
|
||||
Target::Projectile(i) => {
|
||||
let p = &mut self.creatures.projectiles[i];
|
||||
p.position = p.position.add_vector(movement);
|
||||
p.needs_sync = true;
|
||||
}
|
||||
Target::Rocket(i) => {
|
||||
self.rockets[i].position = self.rockets[i].position.add_vector(movement)
|
||||
}
|
||||
Target::Trident(i) => {
|
||||
self.tridents[i].position = self.tridents[i].position.add_vector(movement)
|
||||
Target::Entity(handle) => {
|
||||
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -690,10 +519,6 @@ impl Game<'_> {
|
||||
p.velocity = piston_push::bounce_velocity(movement, p.velocity);
|
||||
}
|
||||
}
|
||||
Target::Mob(i) => {
|
||||
let m = &mut self.mobs[i];
|
||||
m.velocity = piston_push::bounce_velocity(movement, m.velocity);
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
if let Some(host) = self.lan.as_mut()
|
||||
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
@@ -702,47 +527,8 @@ impl Game<'_> {
|
||||
motion.velocity = piston_push::bounce_velocity(movement, motion.velocity);
|
||||
}
|
||||
}
|
||||
Target::Item(i) => self.dropped[i].piston_slime_bounce(movement),
|
||||
Target::Boat(i) => {
|
||||
self.boats[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.boats[i].velocity)
|
||||
}
|
||||
Target::Cart(i) => {
|
||||
self.carts[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.carts[i].velocity)
|
||||
}
|
||||
Target::ArmorStand(i) => {
|
||||
self.armor_stands[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.armor_stands[i].velocity)
|
||||
}
|
||||
Target::Tnt(i) => {
|
||||
self.tnts[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.tnts[i].velocity);
|
||||
// Leave the ground so the next tick does not apply the
|
||||
// on-ground Y * -0.5 friction that cancels the launch.
|
||||
self.tnts[i].on_ground = false;
|
||||
}
|
||||
Target::Falling(i) => {
|
||||
self.falling[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.falling[i].velocity)
|
||||
}
|
||||
Target::Orb(i) => {
|
||||
self.orbs[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.orbs[i].velocity);
|
||||
self.orbs[i].on_ground = false;
|
||||
}
|
||||
Target::Projectile(i) => {
|
||||
let p = &mut self.creatures.projectiles[i];
|
||||
p.velocity = piston_push::bounce_velocity(movement, p.velocity);
|
||||
p.needs_sync = true;
|
||||
}
|
||||
Target::Rocket(i) => {
|
||||
self.rockets[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.rockets[i].velocity)
|
||||
}
|
||||
Target::Trident(i) => {
|
||||
self.tridents[i].velocity =
|
||||
piston_push::bounce_velocity(movement, self.tridents[i].velocity)
|
||||
Target::Entity(handle) => {
|
||||
with_entity_mut!(self, handle, entity => Entity::piston_slime_bounce(entity, movement))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8181,9 +8181,26 @@ impl Game<'_> {
|
||||
self.stop_remote_mining();
|
||||
}
|
||||
if !ready {
|
||||
// A screen's opening released the keys (`KeyMapping.releaseAll`).
|
||||
self.hotbar_presses.clear();
|
||||
return;
|
||||
}
|
||||
// MultiPlayerGameMode.tick syncs the carried slot before keybinds.
|
||||
self.remote_send_carried();
|
||||
let (attack, use_item) = (self.left, self.right);
|
||||
// A tick that clicks, attacks or uses acts with the slot its number
|
||||
// keys select (`ensureHasSentCarriedItem` before the packets).
|
||||
let acts = input.has_clicks() || attack || use_item;
|
||||
input.tick(&mut RemoteInputWorld(self, acts), attack, use_item);
|
||||
self.remote_queued_keys();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.input = input;
|
||||
}
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.ensureHasSentCarriedItem`: the server hears of a
|
||||
/// new selected slot before anything the slot acts with.
|
||||
fn remote_send_carried(&mut self) {
|
||||
let carried_changed = self.remote.as_mut().is_some_and(|r| {
|
||||
if r.carried == Some(self.selected) {
|
||||
false
|
||||
@@ -8197,8 +8214,9 @@ impl Game<'_> {
|
||||
slot: self.selected as i16,
|
||||
}));
|
||||
}
|
||||
let (attack, use_item) = (self.left, self.right);
|
||||
input.tick(&mut RemoteInputWorld(self), attack, use_item);
|
||||
}
|
||||
|
||||
fn remote_queued_keys(&mut self) {
|
||||
// handleKeybinds' keybind polls: the queued presses act now, in tick
|
||||
// order, so no packet goes out between CLIENT_TICK_END and a pong.
|
||||
let pending = self
|
||||
@@ -8229,9 +8247,6 @@ impl Game<'_> {
|
||||
if self.clicks.miss_time > 0 {
|
||||
self.clicks.miss_time -= 1;
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.input = input;
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_start_attack(&mut self) -> bool {
|
||||
@@ -8300,11 +8315,14 @@ impl Game<'_> {
|
||||
} else if let Some((i, distance)) = self.mob_hit()
|
||||
&& self.before_block(distance)
|
||||
{
|
||||
self.send(S::Attack(play::Attack {
|
||||
id: self.mobs[i].id as i32,
|
||||
}));
|
||||
self.remote_player_attack(i);
|
||||
self.reset_attack_strength();
|
||||
// Minecraft.startAttack: an item's attack_range must hold the hit.
|
||||
if self.held_range_allows(distance) {
|
||||
self.send(S::Attack(play::Attack {
|
||||
id: self.mobs[i].id as i32,
|
||||
}));
|
||||
self.remote_player_attack(i);
|
||||
self.reset_attack_strength();
|
||||
}
|
||||
} else if !self.entity_targeted()
|
||||
&& let Some(hit) = self.pick()
|
||||
{
|
||||
@@ -8519,7 +8537,9 @@ impl Game<'_> {
|
||||
// `KeyMapping.releaseAll` clears key.use; without this the button still
|
||||
// reads held and a use would start again on the first tick back.
|
||||
self.right = false;
|
||||
horizon_client::interaction_input::release_on_screen_open(&mut RemoteInputWorld(self));
|
||||
horizon_client::interaction_input::release_on_screen_open(&mut RemoteInputWorld(
|
||||
self, false,
|
||||
));
|
||||
}
|
||||
/// `MultiPlayerGameMode.releaseUsingItem`: sends `RELEASE_USE_ITEM` and
|
||||
/// runs the client half of `Item.releaseUsing` (a Riptide launch moves the
|
||||
@@ -9818,7 +9838,8 @@ fn synced_local_use(
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>);
|
||||
/// The game as the tick's input sees it, and whether the tick acts.
|
||||
struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>, bool);
|
||||
|
||||
struct RemoteUseWorld<'g, 'a>(&'g mut Game<'a>);
|
||||
|
||||
@@ -9836,10 +9857,16 @@ impl horizon_client::interaction_input::TargetUseWorld for RemoteUseWorld<'_, '_
|
||||
|
||||
impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_, '_> {
|
||||
fn pick_hit_result(&mut self) {
|
||||
self.0.tick_hit = Some(self.0.pick());
|
||||
self.0.pick_tick_targets();
|
||||
// MultiPlayerGameMode.ensureHasSentCarriedItem: an action this tick
|
||||
// sends the slot the number keys chose first; otherwise the next
|
||||
// tick's MultiPlayerGameMode.tick does.
|
||||
if self.1 {
|
||||
self.0.remote_send_carried();
|
||||
}
|
||||
}
|
||||
fn release_hit_result(&mut self) {
|
||||
self.0.tick_hit = None;
|
||||
self.0.release_tick_targets();
|
||||
}
|
||||
fn using_item(&self) -> bool {
|
||||
self.0.using.is_some()
|
||||
|
||||
@@ -795,7 +795,10 @@ impl Game<'_> {
|
||||
out.push(json!({"id": c.id, "kind": "minecraft:end_crystal", "position": at(c.position), "show_bottom": c.show_bottom}));
|
||||
}
|
||||
for t in &self.tridents {
|
||||
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position)}));
|
||||
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position), "velocity": at(t.velocity)}));
|
||||
}
|
||||
for r in &self.rockets {
|
||||
out.push(json!({"id": r.id, "kind": "minecraft:firework_rocket", "position": at(r.position), "velocity": at(r.velocity)}));
|
||||
}
|
||||
for p in &self.creatures.projectiles {
|
||||
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity),
|
||||
|
||||
@@ -237,9 +237,17 @@ impl Game<'_> {
|
||||
cart
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
self.previous_position = p.position;
|
||||
// The tick a rider takes its seat is a teleport (`Entity.moveTo`,
|
||||
// which sets the old position too): the camera must not sweep from
|
||||
// where the player stood to the cushion, boat or cart. The ticks
|
||||
// after it move with the vehicle and interpolate as usual.
|
||||
let first = self.posed_rider != self.riding;
|
||||
self.posed_rider = self.riding;
|
||||
if let Some(v) = seat {
|
||||
self.previous_position = if first { v } else { p.position };
|
||||
p.position = v;
|
||||
} else {
|
||||
self.previous_position = p.position;
|
||||
}
|
||||
p.velocity = Vec3::ZERO;
|
||||
p.fall_distance = 0.0;
|
||||
|
||||
@@ -670,8 +670,9 @@ impl Game<'_> {
|
||||
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
|
||||
let g = &mut host.hub.guests[i];
|
||||
g.yaw = yaw;
|
||||
// A new body at the exit: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
// A new body at the exit: no fall distance or motion carried over,
|
||||
// but the player's attributes (ServerPlayer.teleport keeps the entity).
|
||||
g.new_body(true);
|
||||
let at = [position.x, position.y, position.z];
|
||||
if portal {
|
||||
host.hub.land(i, at);
|
||||
@@ -727,8 +728,9 @@ impl Game<'_> {
|
||||
let g = &mut host.hub.guests[i];
|
||||
let [yaw, pitch] = t.rotation.unwrap_or([g.yaw, g.pitch]);
|
||||
let origin = g.position;
|
||||
// A new body there: no fall distance or motion carried over.
|
||||
g.motion = None;
|
||||
// A new body there: no fall distance or motion carried over, but
|
||||
// the player's attributes (ServerPlayer.teleport keeps the entity).
|
||||
g.new_body(true);
|
||||
let cooldown = host.play.get(&t.conn).map_or(0, |p| p.portal.cooldown);
|
||||
let ctx = self.server_ctx();
|
||||
host.hub
|
||||
|
||||
@@ -38,6 +38,10 @@ fn sign_box() -> EditBox {
|
||||
edit
|
||||
}
|
||||
|
||||
/// The Done button's text, `CommonComponents.GUI_DONE`, in the selected language.
|
||||
fn done_label() -> String {
|
||||
super::screen_info::tr("gui.done", &[])
|
||||
}
|
||||
/// AbstractSignEditScreen.init's Done button: `width / 2 - 100`,
|
||||
/// `height / 4 + 144`, int GUI pixels.
|
||||
fn done_button(w: f32, h: f32) -> (f32, f32) {
|
||||
@@ -841,11 +845,7 @@ impl Game<'_> {
|
||||
}
|
||||
let (bx, by) = done_button(w, h);
|
||||
horizon_client::widgets::narratable(|| {
|
||||
horizon_client::widgets::Narratable::button(
|
||||
super::screen_info::tr("gui.done", &[]),
|
||||
[bx, by, 200.0, 20.0],
|
||||
true,
|
||||
)
|
||||
horizon_client::widgets::Narratable::button(done_label(), [bx, by, 200.0, 20.0], true)
|
||||
});
|
||||
batch.sprite_scaled(
|
||||
atlas,
|
||||
@@ -856,10 +856,11 @@ impl Game<'_> {
|
||||
20.0,
|
||||
u32::MAX,
|
||||
);
|
||||
let tw = HudBatch::text_width(atlas, "Done");
|
||||
let done = done_label();
|
||||
let tw = HudBatch::text_width(atlas, &done);
|
||||
batch.text(
|
||||
atlas,
|
||||
"Done",
|
||||
&done,
|
||||
bx + 100.0 - (tw / 2.0).floor(),
|
||||
by + 6.0,
|
||||
u32::MAX,
|
||||
@@ -876,4 +877,27 @@ mod tests {
|
||||
assert_eq!(super::done_button(427.0, 334.0), (113.0, 227.0));
|
||||
assert_eq!(super::done_button(320.0, 240.0), (60.0, 204.0));
|
||||
}
|
||||
|
||||
/// The Done button said "Done" in every language (#1927).
|
||||
#[test]
|
||||
fn done_button_reads_the_selected_language() {
|
||||
use horizon_client::lang::{Language, ResourceSource, scoped};
|
||||
struct Vanilla;
|
||||
impl ResourceSource for Vanilla {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
match path {
|
||||
"assets/minecraft/lang/en_us.json" => Some(br#"{"gui.done": "Done"}"#.to_vec()),
|
||||
"assets/minecraft/lang/de_de.json" => {
|
||||
Some(br#"{"gui.done": "Fertig"}"#.to_vec())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
for (code, want) in [("de_de", "Fertig"), ("en_us", "Done")] {
|
||||
let (label, missed) = scoped(Language::load(&[&Vanilla], code), super::done_label);
|
||||
assert_eq!(label, want, "{code}");
|
||||
assert!(missed.is_empty(), "{code}: {missed:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,20 +326,14 @@ impl Game<'_> {
|
||||
(false, false) => "gui/sprites/widget/tab",
|
||||
};
|
||||
ui::nine_slice(batch, atlas, sprite, x, 0.0, tw, TAB_H, u32::MAX);
|
||||
let label = Text::translate(key, &[]);
|
||||
let lw = atlas.font.width_text(&label);
|
||||
let color = if selected && active {
|
||||
u32::MAX
|
||||
} else {
|
||||
0xFFA0_A0A0
|
||||
};
|
||||
batch.text_styled(
|
||||
ui::menu_tab_label(
|
||||
batch,
|
||||
atlas,
|
||||
&label,
|
||||
(x + (tw - lw) / 2.0).floor(),
|
||||
8.0,
|
||||
color,
|
||||
true,
|
||||
&Text::translate(key, &[]),
|
||||
(x, 0.0, tw, TAB_H),
|
||||
selected,
|
||||
active,
|
||||
horizon_client::hud_overlays::now_ms(),
|
||||
);
|
||||
if selected {
|
||||
batch.fill(atlas, x + 2.0, TAB_H - 3.0, tw - 4.0, 1.0, u32::MAX);
|
||||
|
||||
@@ -45,7 +45,7 @@ impl PrimedTnt {
|
||||
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
|
||||
assets: &horizon_client::assets::Assets,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
) -> Result<()> {
|
||||
) -> Result<Vec<BlockPos>> {
|
||||
self.previous = self.position;
|
||||
if !self.no_gravity {
|
||||
self.velocity.y -= 0.04;
|
||||
@@ -74,14 +74,17 @@ impl PrimedTnt {
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
}
|
||||
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it.
|
||||
horizon_client::motion::bubble_column_push(
|
||||
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it, and the
|
||||
// columns it is at the top of send their surface particles.
|
||||
let mut tops = Vec::new();
|
||||
horizon_client::motion::bubble_column_push_over(
|
||||
world,
|
||||
assets.registry,
|
||||
facts,
|
||||
collision,
|
||||
self.bounds(),
|
||||
&mut self.velocity,
|
||||
|pos| tops.push(pos),
|
||||
);
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.98,
|
||||
@@ -120,7 +123,7 @@ impl PrimedTnt {
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(tops)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,12 +202,16 @@ impl Game<'_> {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
t.tick(
|
||||
let tops = t.tick(
|
||||
&self.world,
|
||||
self.collision,
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
)?;
|
||||
for pos in tops {
|
||||
self.bubble_column_particles(pos);
|
||||
}
|
||||
let t = &mut self.tnts[i];
|
||||
if t.fuse <= 0 {
|
||||
let center = [
|
||||
t.position.x,
|
||||
@@ -766,4 +773,70 @@ mod tests {
|
||||
tnt.position.y
|
||||
);
|
||||
}
|
||||
|
||||
/// `Entity.onAboveBubbleColumn` sends the surface particles only from the top of
|
||||
/// a column: `tick` names the column block it is at the top of, and none while
|
||||
/// the column has water above it.
|
||||
#[test]
|
||||
fn primed_tnt_reports_the_surface_of_a_bubble_column() {
|
||||
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 = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let soul_sand = assets
|
||||
.registry
|
||||
.default_state("minecraft:soul_sand")
|
||||
.unwrap();
|
||||
let default = assets
|
||||
.registry
|
||||
.default_state("minecraft:bubble_column")
|
||||
.unwrap();
|
||||
let mut up = assets.registry.state(default).unwrap().clone();
|
||||
up.properties.insert("drag".into(), "false".into());
|
||||
let column = assets.registry.resolve(&up).unwrap();
|
||||
for y in 3..=8 {
|
||||
world.set(20, y, 20, column).unwrap();
|
||||
}
|
||||
world.set(20, 2, 20, soul_sand).unwrap();
|
||||
let at = Vec3::new(20.5, 6.0, 20.5);
|
||||
let primed = || PrimedTnt {
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
fuse: 80,
|
||||
on_ground: false,
|
||||
owner: None,
|
||||
guest: None,
|
||||
};
|
||||
let mut tops = Vec::new();
|
||||
let mut tnt = primed();
|
||||
for _ in 0..30 {
|
||||
tops.extend(tnt.tick(&world, &collision, &assets, &facts).unwrap());
|
||||
}
|
||||
assert!(!tops.is_empty(), "it reaches the top of the column");
|
||||
assert!(
|
||||
tops.iter().all(|p| *p == BlockPos::new(20, 8, 20)),
|
||||
"only the surface block, got {tops:?}"
|
||||
);
|
||||
// Water above the top block: it is no longer the surface.
|
||||
world.set(20, 9, 20, water).unwrap();
|
||||
let mut tnt = primed();
|
||||
for _ in 0..10 {
|
||||
assert!(
|
||||
tnt.tick(&world, &collision, &assets, &facts)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ impl Game<'_> {
|
||||
let mut nodes = widgets::tree(&widgets::last_labelled(), &widgets::last_targets(), size);
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
&& self.title.is_none()
|
||||
&& self.book.is_none()
|
||||
&& let Ok((ox, oy)) = self.gui_origin()
|
||||
{
|
||||
nodes.extend(self.slots().into_iter().map(|i| {
|
||||
|
||||
@@ -426,6 +426,35 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
self.falling = falling;
|
||||
// Firework rockets (0.25 wide, eyes at 0.85 of it), tridents in flight
|
||||
// (0.5 wide, eyes at 0.13; like any arrow not pushed in the ground)
|
||||
// take `Entity.pushFromExplosion` alike.
|
||||
let mut rockets = std::mem::take(&mut self.rockets);
|
||||
for r in &mut rockets {
|
||||
let p = r.position;
|
||||
push_box(
|
||||
self,
|
||||
p,
|
||||
[p.x - 0.125, p.y, p.z - 0.125],
|
||||
[p.x + 0.125, p.y + 0.25, p.z + 0.125],
|
||||
0.2125,
|
||||
&mut r.velocity,
|
||||
);
|
||||
}
|
||||
self.rockets = rockets;
|
||||
let mut tridents = std::mem::take(&mut self.tridents);
|
||||
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
|
||||
let p = t.position;
|
||||
push_box(
|
||||
self,
|
||||
p,
|
||||
[p.x - 0.25, p.y, p.z - 0.25],
|
||||
[p.x + 0.25, p.y + 0.5, p.z + 0.25],
|
||||
f64::from(0.13_f32),
|
||||
&mut t.velocity,
|
||||
);
|
||||
}
|
||||
self.tridents = tridents;
|
||||
// VehicleEntity.ignoreExplosion: a mob's burst (a breeze's) leaves
|
||||
// boats and minecarts alone with mob griefing off.
|
||||
if !(breeze && !self.ui.rules.bool("mob_griefing")) {
|
||||
|
||||
@@ -826,6 +826,57 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A recipe filter's `replace_input` (#5910) reaches the book swapped:
|
||||
/// the book writes from the network copy, so the swap must have run
|
||||
/// there, once, before the ingredient normalization. A world's pack
|
||||
/// carries the filter (the way the game reads them).
|
||||
#[test]
|
||||
fn a_recipe_filters_swapped_input_shows_swapped_in_the_book() {
|
||||
let root = resources(
|
||||
"filter_swap",
|
||||
&[(
|
||||
"copper_ingot_from_smelting_raw_copper",
|
||||
r#"{"type":"minecraft:smelting","cookingtime":200,"experience":0.7,
|
||||
"ingredient":"minecraft:raw_copper",
|
||||
"result":{"id":"minecraft:copper_ingot"}}"#,
|
||||
)],
|
||||
);
|
||||
let packs = vec![(
|
||||
"recipe_filter/minecraft:swap".to_owned(),
|
||||
vec![
|
||||
br#"{"filters":[{"id":"minecraft:copper_ingot_from_smelting_raw_copper",
|
||||
"replace_input":{"from":"minecraft:raw_copper","to":"minecraft:redstone"}}]}"#
|
||||
.to_vec(),
|
||||
],
|
||||
)];
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &packs };
|
||||
let sync = SyncData::load(&root, &overlay).unwrap();
|
||||
let entries = sync.advancements.book_entries(
|
||||
"minecraft:copper_ingot_from_smelting_raw_copper",
|
||||
&sync,
|
||||
NOTIFY_AND_HIGHLIGHT,
|
||||
);
|
||||
assert_eq!(entries.len(), 1, "the recipe has one display");
|
||||
let mut r = Reader::new(&entries[0]);
|
||||
let e = RecipeBookEntry::read(&mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0, "the entry ends with its flags");
|
||||
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
|
||||
assert_eq!(
|
||||
e.requirements,
|
||||
Some(vec![HolderSet::Items(vec![item("minecraft:redstone")])]),
|
||||
"the filter's swapped input, not the file's"
|
||||
);
|
||||
let RecipeDisplay::Furnace { ingredient, .. } = e.display else {
|
||||
panic!("a smelting recipe shows a furnace display");
|
||||
};
|
||||
assert_eq!(
|
||||
ingredient,
|
||||
SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:redstone")])),
|
||||
"the display shows the swapped input too"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blasting_reaches_the_blast_furnace_book() {
|
||||
let root = resources(
|
||||
|
||||
@@ -271,8 +271,7 @@ impl SyncData {
|
||||
super::advancements::ServerAdvancements::load(resources, overlay, recipes, groups)
|
||||
},
|
||||
};
|
||||
sync.recipes =
|
||||
super::recipes::update_recipes(&sync.advancements.recipes, resources, &sync, overlay);
|
||||
sync.recipes = super::recipes::update_recipes(&sync.advancements.recipes, &sync);
|
||||
Ok(sync)
|
||||
}
|
||||
|
||||
|
||||
@@ -214,6 +214,11 @@ pub struct Guest {
|
||||
/// What the player's file says of the body (`Entity.load`: the fire
|
||||
/// and air ticks and the fall distance), for the first body it gets.
|
||||
pub loaded_body: Option<SavedBody>,
|
||||
/// The attribute list the player's last body hands to its next one
|
||||
/// (`ServerPlayer.restoreFrom`, `PlayerMotion::carried_attributes`),
|
||||
/// until that body exists: the scale a player has set stays through a
|
||||
/// respawn and a change of level (#3684).
|
||||
pub carried_attributes: Option<horizon_world::nbt::Tag>,
|
||||
/// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s
|
||||
/// "RootVehicle"), for the ride to come back on the next login.
|
||||
pub saved_vehicle: Option<[i32; 4]>,
|
||||
@@ -410,6 +415,7 @@ impl Guest {
|
||||
tracker: Tracker::default(),
|
||||
motion: None,
|
||||
loaded_body: None,
|
||||
carried_attributes: None,
|
||||
saved_vehicle: None,
|
||||
logs_kept: None,
|
||||
inventory: vec![None; 36],
|
||||
@@ -600,14 +606,39 @@ impl Guest {
|
||||
Pose::Standing
|
||||
}
|
||||
}
|
||||
/// `Avatar.POSES` for the guest's pose, with its scale attribute.
|
||||
/// `Avatar.POSES` for the guest's pose, with its scale attribute: also for
|
||||
/// a pose the client has announced (gliding) before the body's own tick
|
||||
/// has taken it.
|
||||
pub fn dimensions(&self) -> Dimensions {
|
||||
let pose = self.pose();
|
||||
match &self.motion {
|
||||
Some(m) if m.pose == pose => m.dimensions,
|
||||
_ => pose.dimensions(1.0),
|
||||
Some(m) => pose.dimensions(m.scale),
|
||||
None => pose.dimensions(1.0),
|
||||
}
|
||||
}
|
||||
/// The player's body is replaced (`ServerPlayer.restoreFrom`): the next
|
||||
/// one starts from the old one's attributes, all of them when it is the
|
||||
/// same player in another level (`restore_all`), only their base values
|
||||
/// after a death. The old body, for what else it hands over.
|
||||
pub fn new_body(&mut self, restore_all: bool) -> Option<Box<PlayerMotion>> {
|
||||
let m = self.motion.take()?;
|
||||
self.carried_attributes = Some(m.carried_attributes(restore_all));
|
||||
Some(m)
|
||||
}
|
||||
/// `ServerPlayer.restoreFrom(old, false)`'s `setHealth(getMaxHealth())`:
|
||||
/// the health a player comes back from a death with, the max health of
|
||||
/// the base values their next body takes over, before its equipment
|
||||
/// modifies it (40 after `/attribute @s minecraft:max_health base set
|
||||
/// 40`, not 20).
|
||||
pub fn respawn_health(&self, resources: &crate::motion::MotionResources) -> f32 {
|
||||
let Ok(mut body) = PlayerMotion::new(resources, Vec3::ZERO) else {
|
||||
return 20.0;
|
||||
};
|
||||
let _ = body.apply_saved_attributes(self.carried_attributes.as_ref());
|
||||
body.attribute("minecraft:max_health")
|
||||
.map_or(20.0, |max| max as f32)
|
||||
}
|
||||
/// `Entity.getBoundingBox`.
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let [x, y, z] = self.position;
|
||||
@@ -1384,4 +1415,102 @@ mod tests {
|
||||
Some(Component::Text(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// The prepared game data a body needs, or `None` without it.
|
||||
fn resources() -> Option<(crate::assets::Assets, crate::motion::MotionResources)> {
|
||||
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() {
|
||||
eprintln!("skipped: prepared resources unavailable");
|
||||
return None;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).ok()?;
|
||||
let resources = crate::motion::MotionResources::load(&root, &assets).ok()?;
|
||||
Some((assets, resources))
|
||||
}
|
||||
|
||||
/// `/attribute @s <attribute> base set`, as `AttributeCommand` runs it,
|
||||
/// and the next tick's `refreshDirtyAttributes` (the box of a new scale).
|
||||
fn set_base(m: &mut PlayerMotion, attribute: &str, value: f64) {
|
||||
let key = m.attributes.key(attribute).unwrap();
|
||||
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
|
||||
m.attributes
|
||||
.edit(&handle, |attribute, observer| {
|
||||
attribute.set_base_value(value, observer)
|
||||
})
|
||||
.unwrap();
|
||||
m.refresh_attributes().unwrap();
|
||||
}
|
||||
|
||||
fn assert_dims(got: Dimensions, want: (f32, f32, f32), what: &str) {
|
||||
let close = |a: f32, b: f32| (a - b).abs() <= 1e-5 * b.abs().max(1.0);
|
||||
assert!(
|
||||
close(got.width, want.0) && close(got.height, want.1) && close(got.eye_height, want.2),
|
||||
"{what}: {got:?}, want {want:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `ServerPlayer.restoreFrom(old, false)` takes the base values
|
||||
/// (`assignBaseValues`), then `setHealth(getMaxHealth())`: a player whose
|
||||
/// max health base was set comes back with that much, not 20, and a
|
||||
/// permanent modifier (`/attribute ... modifier add`) does not count, as
|
||||
/// it stays with the dead body. The attribute's range is 1..1024.
|
||||
#[test]
|
||||
fn a_respawned_player_has_the_max_health_of_their_base_values() {
|
||||
use horizon_world::attributes::{Modifier, Operation};
|
||||
let Some((_assets, resources)) = resources() else {
|
||||
return;
|
||||
};
|
||||
for (base, bonus, want) in [
|
||||
(40.0, 0.0, 40.0),
|
||||
(10.0, 0.0, 10.0),
|
||||
(20.0, 20.0, 20.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(2048.0, 0.0, 1024.0),
|
||||
] {
|
||||
let mut g = guest_at([0.5, 64.0, 0.5]);
|
||||
let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
|
||||
set_base(&mut m, "minecraft:max_health", base);
|
||||
let key = m.attributes.key("minecraft:max_health").unwrap();
|
||||
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
|
||||
let bonus = Modifier::new("test:bonus", bonus, Operation::AddValue).unwrap();
|
||||
m.attributes
|
||||
.edit(&handle, |a, o| a.add_permanent(bonus, o))
|
||||
.unwrap();
|
||||
g.motion = Some(Box::new(m));
|
||||
assert!(g.new_body(false).is_some());
|
||||
assert_eq!(g.respawn_health(&resources), want, "base {base}");
|
||||
}
|
||||
// No body to take anything from: the default.
|
||||
let g = guest_at([0.5, 64.0, 0.5]);
|
||||
assert_eq!(g.respawn_health(&resources), 20.0);
|
||||
}
|
||||
|
||||
/// #3684: a glide the client announced (`START_FALL_FLYING`) is the
|
||||
/// player's pose before its body's own tick has taken it: the box in
|
||||
/// between is the gliding one at the player's scale, not at scale 1 (a
|
||||
/// 0.6 box on a player 0.1 or 2 times as big sent them back from a wall).
|
||||
#[test]
|
||||
fn a_glide_announced_before_the_body_ticks_has_the_scaled_box() {
|
||||
let Some((_assets, resources)) = resources() else {
|
||||
return;
|
||||
};
|
||||
for (scale, side) in [(0.1_f64, 0.06_f32), (2.0, 1.2)] {
|
||||
let mut g = guest_at([0.5, 64.0, 0.5]);
|
||||
let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
|
||||
set_base(&mut m, "minecraft:scale", scale);
|
||||
m.set_pose(Pose::Standing).unwrap();
|
||||
g.motion = Some(Box::new(m));
|
||||
g.fall_flying = true;
|
||||
assert_eq!(g.pose(), Pose::FallFlying);
|
||||
assert_ne!(g.motion.as_ref().unwrap().pose, Pose::FallFlying);
|
||||
let d = g.dimensions();
|
||||
assert_dims(
|
||||
d,
|
||||
(side, side, side * (0.4 / 0.6)),
|
||||
&format!("scale {scale}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,12 +275,19 @@ pub fn store(guest: &Guest, level: &mut Level, vehicle: Option<Tag>) {
|
||||
}
|
||||
// LivingEntity.addAdditionalSaveData: the live body's AttributeMap.pack,
|
||||
// or the list the file had while the player has no body yet.
|
||||
if let Some(m) = guest.motion.as_deref() {
|
||||
// A player between two bodies (a respawn, a change of level) saves what
|
||||
// the next one takes over.
|
||||
let attributes = guest
|
||||
.motion
|
||||
.as_deref()
|
||||
.map(crate::motion::PlayerMotion::saved_attributes)
|
||||
.or_else(|| guest.carried_attributes.clone());
|
||||
if let Some(attributes) = attributes {
|
||||
let extra = level
|
||||
.player_extra
|
||||
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
|
||||
if let horizon_world::nbt::Tag::Compound(map) = extra {
|
||||
map.insert("attributes".into(), m.saved_attributes());
|
||||
map.insert("attributes".into(), attributes);
|
||||
}
|
||||
}
|
||||
level.recipe_book = guest.recipe_book.clone();
|
||||
@@ -543,6 +550,89 @@ mod tests {
|
||||
Some((assets, resources))
|
||||
}
|
||||
|
||||
/// A body at scale 0.1 with a permanent +0.4 on its step height, such as
|
||||
/// a command or a datapack gives.
|
||||
fn small_body(resources: &crate::motion::MotionResources) -> crate::motion::PlayerMotion {
|
||||
use horizon_world::attributes::{Modifier, Operation};
|
||||
let mut m = crate::motion::PlayerMotion::new(resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.expect("a body");
|
||||
let key = m.attributes.key("minecraft:scale").unwrap();
|
||||
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
|
||||
m.attributes
|
||||
.edit(&handle, |a, o| a.set_base_value(0.1, o))
|
||||
.unwrap();
|
||||
let key = m.attributes.key("minecraft:step_height").unwrap();
|
||||
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
|
||||
let step = Modifier::new("test:step", 0.4, Operation::AddValue).unwrap();
|
||||
m.attributes
|
||||
.edit(&handle, |a, o| a.add_permanent(step, o))
|
||||
.unwrap();
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_body_takes_the_scale_of_the_old_one() {
|
||||
// #3684: ServerPlayer.restoreFrom copies the base values to the body
|
||||
// that follows a death (assignBaseValues) and, for the same player in
|
||||
// another level, the permanent modifiers too (assignPermanentModifiers).
|
||||
// The body Horizon made on a respawn or a level change started from
|
||||
// the defaults: a player at /attribute scale 0.1 was 1.0 again.
|
||||
let Some((_assets, resources)) = resources() else {
|
||||
eprintln!("skipped: prepared resources unavailable");
|
||||
return;
|
||||
};
|
||||
let next = |g: &Guest| {
|
||||
let mut m =
|
||||
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.expect("a body");
|
||||
m.apply_saved_attributes(g.carried_attributes.as_ref())
|
||||
.unwrap();
|
||||
m
|
||||
};
|
||||
for (restore_all, step) in [(false, 0.6), (true, 1.0)] {
|
||||
let mut g = guest();
|
||||
g.motion = Some(Box::new(small_body(&resources)));
|
||||
g.new_body(restore_all);
|
||||
assert!(g.motion.is_none());
|
||||
let mut m = next(&g);
|
||||
assert_eq!(m.attribute("minecraft:scale").unwrap(), 0.1);
|
||||
assert!((m.attribute("minecraft:step_height").unwrap() - step).abs() < 1e-9);
|
||||
// The tick that follows rebuilds the box at that scale.
|
||||
m.refresh_attributes().unwrap();
|
||||
assert_eq!(m.dimensions.width, 0.6 * 0.1);
|
||||
assert_eq!(m.dimensions.height, 1.8 * 0.1);
|
||||
}
|
||||
// No body before: nothing to hand over.
|
||||
let mut g = guest();
|
||||
g.new_body(true);
|
||||
assert!(g.carried_attributes.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_player_between_two_bodies_saves_the_attributes_the_next_one_takes() {
|
||||
// #3684: logging out after a respawn, before the new body has ticked,
|
||||
// must not write the file's older list over the scale.
|
||||
let Some((_assets, resources)) = resources() else {
|
||||
eprintln!("skipped: prepared resources unavailable");
|
||||
return;
|
||||
};
|
||||
let mut g = guest();
|
||||
g.motion = Some(Box::new(small_body(&resources)));
|
||||
g.new_body(false);
|
||||
let mut level = Level::default();
|
||||
store(&g, &mut level, None);
|
||||
let tag = level.player_tag();
|
||||
let mut read = Level::default();
|
||||
read.read_player(&tag);
|
||||
read.player_extra = Some(tag);
|
||||
let mut back =
|
||||
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
|
||||
.expect("a body");
|
||||
back.apply_saved_attributes(read.player_extra.as_ref().and_then(|t| t.get("attributes")))
|
||||
.unwrap();
|
||||
assert_eq!(back.attribute("minecraft:scale").unwrap(), 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_air_comes_back() {
|
||||
// A player saved underwater (#1321): the file keeps the remaining air
|
||||
|
||||
@@ -96,6 +96,12 @@ pub(super) fn network_recipes(
|
||||
let mut all = BTreeMap::new();
|
||||
recipes(&resources.join("data/minecraft/recipe"), "", &mut all);
|
||||
overlay_recipes(overlay, &mut all);
|
||||
// The data's recipe filters, as the server's own tables apply them
|
||||
// (#5910), once, before the ingredient normalization, so a swapped
|
||||
// name resolves and every copy — the book's displays, the property
|
||||
// sets — agrees on the swapped recipe.
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay);
|
||||
all.retain(|id, r| filters.apply(id, r));
|
||||
match horizon_client::items::Items::catalog(resources, overlay) {
|
||||
Ok(items) => all.retain(|_, r| normalize_ingredients(r, &items)),
|
||||
Err(e) => log::warn!("recipes: NeoForge's ingredient shapes stay unresolved: {e}"),
|
||||
@@ -190,26 +196,14 @@ fn normalize_ingredients(r: &mut Value, items: &horizon_client::items::Items) ->
|
||||
}
|
||||
|
||||
/// The packet's payload for the network copy's recipes (`network_recipes`).
|
||||
pub fn update_recipes(
|
||||
all: &BTreeMap<String, Box<str>>,
|
||||
resources: &Path,
|
||||
sync: &SyncData,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
) -> Vec<u8> {
|
||||
// The data's recipe filters, as the server's own tables apply them. A
|
||||
// filter can also swap a recipe's inputs and outputs, so it runs on
|
||||
// this packet's own parsed values, the book's copy keeping its files
|
||||
// as loaded. Parsing every recipe takes about a millisecond.
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay);
|
||||
let mut chosen: Vec<Value> = Vec::new();
|
||||
for (id, text) in all {
|
||||
let Ok(mut own) = serde_json::from_str::<Value>(text) else {
|
||||
continue;
|
||||
};
|
||||
if filters.apply(id, &mut own) {
|
||||
chosen.push(own);
|
||||
}
|
||||
}
|
||||
pub fn update_recipes(all: &BTreeMap<String, Box<str>>, sync: &SyncData) -> Vec<u8> {
|
||||
// The copy is already filtered and swapped (`network_recipes`); the
|
||||
// filters never run twice, so one swap cannot feed a later one again.
|
||||
// Parsing every recipe takes about a millisecond.
|
||||
let chosen: Vec<Value> = all
|
||||
.values()
|
||||
.filter_map(|text| serde_json::from_str::<Value>(text).ok())
|
||||
.collect();
|
||||
let tags: BTreeMap<&str, &[i32]> = sync
|
||||
.tags
|
||||
.registries
|
||||
|
||||
@@ -297,6 +297,9 @@ pub struct Menu {
|
||||
/// Recipes whose result was taken (`awardUsedRecipes`) and the stacks that
|
||||
/// went into each, for the caller.
|
||||
pub crafted: Vec<(String, Vec<Option<ItemStack>>)>,
|
||||
/// A crafting grid's result taken (`ResultSlot.onTake`): the recipe and
|
||||
/// the stack taken, for the caller (`native_item_crafted`).
|
||||
pub item_crafted: Vec<(String, ItemStack)>,
|
||||
/// Potions taken from a brewing stand's bottle slots
|
||||
/// (`PotionSlot.onTake`), for the caller.
|
||||
pub brewed: Vec<ItemStack>,
|
||||
@@ -729,6 +732,7 @@ impl Menu {
|
||||
sounds: Vec::new(),
|
||||
player_sounds: Vec::new(),
|
||||
crafted: Vec::new(),
|
||||
item_crafted: Vec::new(),
|
||||
brewed: Vec::new(),
|
||||
equipped: Vec::new(),
|
||||
merchant: None,
|
||||
@@ -1732,6 +1736,8 @@ impl Menu {
|
||||
let grid = self.containers[GRID].clone();
|
||||
if let Some((id, _)) = env.recipes.find(&grid, n) {
|
||||
self.crafted.push((format!("minecraft:{id}"), grid.clone()));
|
||||
self.item_crafted
|
||||
.push((format!("minecraft:{id}"), taken.clone()));
|
||||
}
|
||||
let remaining = env
|
||||
.recipes
|
||||
@@ -3292,6 +3298,25 @@ pub(crate) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// A crafting grid's result taken is recorded for `native_item_crafted`:
|
||||
/// the recipe the grid matched and the stack the player took.
|
||||
#[test]
|
||||
fn taking_a_crafting_result_records_the_crafted_stack() {
|
||||
with_menu(Kind::Crafting, |env, menu| {
|
||||
menu.creative = false;
|
||||
menu.set_slot(env, 1, stack("oak_log", 1));
|
||||
menu.settle(env);
|
||||
menu.click(env, 0, 0, Input::Pickup).unwrap();
|
||||
assert_eq!(
|
||||
menu.item_crafted,
|
||||
vec![(
|
||||
"minecraft:oak_planks".to_owned(),
|
||||
stack("oak_planks", 4).unwrap()
|
||||
)]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_move_landing_routes_plain_containers() {
|
||||
for (kind, size) in [
|
||||
|
||||
@@ -2799,6 +2799,11 @@ impl Mob {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// LivingEntity.getDamageAfterArmorAbsorb asks `hurtArmor` *before*
|
||||
// `CombatRules.getDamageAfterAbsorb`, so a worn piece wears by the
|
||||
// damage the armour, toughness and enchantment reductions have not
|
||||
// touched yet, and only a hit outside `#bypasses_armor` asks at all.
|
||||
let armor_hit = (!source.bypasses_armor() && amount > 0.0).then_some(amount);
|
||||
if !source.bypasses_armor() && self.kind.armor + worn + covered > 0.0 {
|
||||
// CombatRules.getDamageAfterAbsorb
|
||||
let armor = self.kind.armor + worn + covered;
|
||||
@@ -2881,17 +2886,19 @@ impl Mob {
|
||||
if !absorbed && dealt != 0.0 {
|
||||
self.record_damage(source, dealt);
|
||||
}
|
||||
// LivingEntity.hurtArmor: the body armour a mob wears (a horse's) wears
|
||||
// on a hit that reached it. A wolf's armour has its own rule
|
||||
// (`Wolf.actuallyHurt`, `hurt_wolf_armor`).
|
||||
if dealt > 0.0
|
||||
// LivingEntity.hurtArmor: `Horse` is the only mob that overrides it, and
|
||||
// it hands `doHurtEquipment` the body slot alone. A wolf's armour has
|
||||
// its own rule (`Wolf.actuallyHurt`, `hurt_wolf_armor`) and a player's
|
||||
// four pieces theirs, so the other "body slot" species — donkey, mule,
|
||||
// llama, camel, the skeleton and zombie horses — wear nothing, whatever
|
||||
// `drops::head_is_body` says about where the item lives.
|
||||
if let Some(damage) = armor_hit
|
||||
&& self.species() == Species::Horse
|
||||
&& self.extra.head.is_some()
|
||||
&& self.species() != Species::Wolf
|
||||
&& drops::head_is_body(self.species())
|
||||
{
|
||||
self.outbox.push(MobEvent::ArmorHurt {
|
||||
mob: self.id,
|
||||
amount: dealt,
|
||||
amount: damage,
|
||||
kind: source.kind,
|
||||
});
|
||||
}
|
||||
@@ -4325,10 +4332,14 @@ mod armor_hurt_tests {
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
fn horse() -> Mob {
|
||||
mob("minecraft:horse", Species::Horse)
|
||||
}
|
||||
|
||||
fn mob(id: &str, species: Species) -> Mob {
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:horse".into();
|
||||
k.species = Species::Horse;
|
||||
k.id = id.into();
|
||||
k.species = species;
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
@@ -4363,6 +4374,72 @@ mod armor_hurt_tests {
|
||||
.any(|e| matches!(e, MobEvent::ArmorHurt { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
/// `Horse` is the only mob that overrides `LivingEntity.hurtArmor` (the
|
||||
/// base one is empty, `LivingEntity.java:1887`), so a donkey wearing a body
|
||||
/// item — `/item replace entity @e[type=minecraft:donkey,limit=1]
|
||||
/// armor.body with minecraft:wolf_armor` — wears nothing, even though
|
||||
/// `drops::head_is_body` stores a donkey's body item in the slot a horse's
|
||||
/// goes in.
|
||||
#[test]
|
||||
fn a_donkey_wearing_a_body_item_wears_nothing() {
|
||||
let mut donkey = mob("minecraft:donkey", Species::Donkey);
|
||||
donkey.extra.head = Some("minecraft:wolf_armor".into());
|
||||
assert!(
|
||||
donkey.damage(6.0, Damage::of(DamageKind::MobAttack)),
|
||||
"the hit landed"
|
||||
);
|
||||
assert!(
|
||||
!donkey
|
||||
.outbox
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::ArmorHurt { .. })),
|
||||
"only a horse's own override wears its body piece"
|
||||
);
|
||||
}
|
||||
|
||||
/// `getDamageAfterArmorAbsorb` asks `hurtArmor` only when the damage type
|
||||
/// is not in `#bypasses_armor` (`LivingEntity.java:1907-1909`), so a horse
|
||||
/// that falls keeps its body piece whole.
|
||||
#[test]
|
||||
fn a_fall_does_not_wear_the_body_piece() {
|
||||
let mut m = horse();
|
||||
m.extra.head = Some("minecraft:diamond_horse_armor".into());
|
||||
assert!(
|
||||
m.damage(6.0, Damage::of(DamageKind::Fall)),
|
||||
"the fall landed"
|
||||
);
|
||||
assert!(
|
||||
!m.outbox
|
||||
.iter()
|
||||
.any(|e| matches!(e, MobEvent::ArmorHurt { .. })),
|
||||
"a #bypasses_armor hit asks for no wear"
|
||||
);
|
||||
}
|
||||
|
||||
/// The wear is measured by the damage `hurtArmor` is handed, which is the
|
||||
/// one the armour, toughness and enchantment reductions have not touched
|
||||
/// yet: 8 raw damage is `8 / 4 = 2` durability even though plate leaves far
|
||||
/// less than 8 through to the health.
|
||||
#[test]
|
||||
fn the_wear_is_the_damage_before_the_armour_reduction() {
|
||||
let mut m = horse();
|
||||
let k = Arc::make_mut(&mut m.kind);
|
||||
k.armor = 20.0;
|
||||
m.extra.head = Some("minecraft:diamond_horse_armor".into());
|
||||
let before = m.health;
|
||||
assert!(m.damage(8.0, Damage::of(DamageKind::MobAttack)));
|
||||
assert!(m.health < before, "the hit reached the health");
|
||||
let owed = m.outbox.iter().find_map(|e| match e {
|
||||
MobEvent::ArmorHurt { amount, .. } => Some(*amount),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
owed,
|
||||
Some(8.0),
|
||||
"the pre-reduction damage, not the damage the health took"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
//! new geometry.
|
||||
|
||||
use super::{MobType, Part, Polygon};
|
||||
use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem};
|
||||
use horizon_optifine::cem::{
|
||||
files::{atlas_name, jem_files, load_jem},
|
||||
model::{Bone, EntityModel},
|
||||
};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
@@ -130,39 +132,6 @@ impl CemModels {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or
|
||||
/// parsed.
|
||||
fn load_jem(root: &Path, path: &str) -> Option<EntityModel> {
|
||||
let Some(text) = read_small(&root.join(path)) else {
|
||||
log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes");
|
||||
return None;
|
||||
};
|
||||
let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) {
|
||||
Ok(jem) => jem,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: custom entity model {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
for warning in &jem.warnings {
|
||||
log::warn!("Horizon: custom entity model {warning}");
|
||||
}
|
||||
if let Some(texture) = &jem.texture {
|
||||
if atlas_name(texture).is_none() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} is outside \
|
||||
assets/minecraft/textures/, so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
} else if !root.join(texture).is_file() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} doesn't exist, \
|
||||
so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(jem)
|
||||
}
|
||||
|
||||
/// Every model layer of `types` by its key.
|
||||
fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<Part>> {
|
||||
types
|
||||
@@ -171,47 +140,6 @@ fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<P
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pack file's text, None when it's missing or larger than a model may read: its size is
|
||||
/// checked before anything is read.
|
||||
fn read_small(path: &Path) -> Option<String> {
|
||||
let len = fs::metadata(path).ok()?.len();
|
||||
if len > MAX_BYTES as u64 {
|
||||
return None;
|
||||
}
|
||||
fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first:
|
||||
/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat
|
||||
/// `<name>.jem` before `<name>/<name>.jem`.
|
||||
fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
|
||||
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] {
|
||||
let Ok(entries) = fs::read_dir(root.join(base)) else {
|
||||
continue;
|
||||
};
|
||||
let (mut flat, mut nested) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if let Some(model) = name.strip_suffix(".jem") {
|
||||
// A directory named `x.jem` isn't a model.
|
||||
if e.path().is_file() {
|
||||
flat.push((model.to_owned(), format!("{base}/{name}")));
|
||||
}
|
||||
} else {
|
||||
let path = format!("{base}/{name}/{name}.jem");
|
||||
if root.join(&path).is_file() {
|
||||
nested.push((name, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (model, path) in flat.into_iter().chain(nested) {
|
||||
out.entry(model).or_default().push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The CEM model names with a layer key of their own; any other name `n` is `n#main`.
|
||||
const LAYERS: &[(&str, &str)] = &[
|
||||
("sheep_wool", "sheep#wool"),
|
||||
@@ -500,13 +428,6 @@ impl CemLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// The atlas name of a texture resource path: `assets/minecraft/textures/entity/x.png` is
|
||||
/// `entity/x`. Textures outside a `textures/` folder are not in the atlas.
|
||||
fn atlas_name(path: &str) -> Option<String> {
|
||||
let rest = path.strip_prefix("assets/minecraft/textures/")?;
|
||||
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
|
||||
}
|
||||
|
||||
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
|
||||
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
|
||||
if part.name == name {
|
||||
@@ -544,6 +465,7 @@ fn part(bone: &Bone, count: &mut usize, ids: &mut HashMap<String, String>) -> Pa
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn plane(name: &str, children: Vec<Part>) -> Part {
|
||||
Part {
|
||||
@@ -705,35 +627,6 @@ mod tests {
|
||||
Some(part.polygons.len() == 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_files_win_and_a_flat_file_beats_its_folder() {
|
||||
let (replace, attach) = (jem("leg1", false), jem("leg1", true));
|
||||
let files = [
|
||||
// EMF's folder form over OptiFine's flat file.
|
||||
("optifine/cem/cow.jem", attach.as_str()),
|
||||
("emf/cem/cow/cow.jem", replace.as_str()),
|
||||
// One folder: the flat file over the folder form.
|
||||
("optifine/cem/pig.jem", replace.as_str()),
|
||||
("optifine/cem/pig/pig.jem", attach.as_str()),
|
||||
// A directory named `sheep.jem` is no file: the folder form stands.
|
||||
("optifine/cem/sheep.jem/readme.txt", ""),
|
||||
("optifine/cem/sheep/sheep.jem", attach.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("right_hind_leg", vec![])]);
|
||||
let models = models(
|
||||
"precedence",
|
||||
&files,
|
||||
&["cow#main", "pig#main", "sheep#main"],
|
||||
&tree,
|
||||
);
|
||||
assert_eq!(attached(&models, "cow#main", "right_hind_leg"), Some(false));
|
||||
assert_eq!(attached(&models, "pig#main", "right_hind_leg"), Some(false));
|
||||
assert_eq!(
|
||||
attached(&models, "sheep#main", "right_hind_leg"),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baby_with_its_own_file_takes_it_not_the_adults() {
|
||||
let (adult, baby) = (jem("head", false), jem("head", true));
|
||||
|
||||
@@ -549,6 +549,24 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
((vx * fx + vz * fz) / len, (vx * -fz + vz * fx) / len)
|
||||
}
|
||||
};
|
||||
// `Entity.isVisuallySwimming` (`Pose.SWIMMING`, id 3): a player's or mannequin's pose comes
|
||||
// in `avatar_pose`, another mob's in `pose`. A drowned has no such pose, only the shared
|
||||
// swimming flag (`extra.flag`).
|
||||
let swim_pose = match &*m.kind.id {
|
||||
"minecraft:player" | "minecraft:mannequin" => {
|
||||
m.extra.avatar_pose == crate::pose::Pose::Swimming
|
||||
}
|
||||
_ => m.extra.pose == 3 || (m.species() == super::Species::Drowned && m.extra.flag),
|
||||
};
|
||||
// The arm the current swing moves: the main arm unless the swing named its hand.
|
||||
let swinging = m.ai.swinging || m.extra.swing_right_arm.is_some();
|
||||
let swing_right = m.extra.swing_right_arm.unwrap_or(!m.main_arm_left());
|
||||
// `HumanoidArm`'s items: the main hand is on the main arm.
|
||||
let (right_hand, left_hand) = if m.main_arm_left() {
|
||||
(&m.extra.off_hand, &m.extra.main_hand)
|
||||
} else {
|
||||
(&m.extra.main_hand, &m.extra.off_hand)
|
||||
};
|
||||
for (i, (_, p)) in PARAMS.iter().enumerate() {
|
||||
use Param::*;
|
||||
slots[i] = match p {
|
||||
@@ -582,6 +600,8 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
PlayerRotY => frame.player_rot[1],
|
||||
// Random models' rule (a later change); the model's own rule is 0 until then.
|
||||
RuleIndex => 0.0,
|
||||
// A client knows only a wolf's anger end time (as a remaining time taken when it
|
||||
// was synced, which doesn't count down) and never the start: 0 for now.
|
||||
AngerTime | AngerTimeStart => 0.0,
|
||||
Distance => (dx * dx + dy * dy + dz * dz).sqrt() as f32,
|
||||
MoveForward => forward,
|
||||
@@ -589,6 +609,8 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
// How deep the entity is in its fluid, which is what `fluid_depth_down` measures
|
||||
// once it touches the bottom.
|
||||
FluidDepth | FluidDepthDown => m.fluids.water.max(m.fluids.lava) as f32,
|
||||
// Both need the blocks around the mob (the ground below, the fluid's surface
|
||||
// above), which the draw doesn't have: 0 for now.
|
||||
HeightAboveGround | FluidDepthUp => 0.0,
|
||||
// The synced `Mob.isAggressive`, which a client's mobs have too.
|
||||
Aggressive => flag(m.ai.aggressive),
|
||||
@@ -599,10 +621,14 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
Hurt => flag(m.hurt_time > 0),
|
||||
InWater => flag(m.fluids.in_water),
|
||||
InLava => flag(m.fluids.in_lava),
|
||||
// `isInWaterOrRain` without the rain: whether rain reaches the mob needs the
|
||||
// weather and the sky above it, which the draw doesn't have.
|
||||
Wet => flag(m.fluids.in_water),
|
||||
Invisible => flag(m.invisible),
|
||||
OnGround => flag(m.on_ground),
|
||||
Riding => flag(m.extra.passenger),
|
||||
// `Entity.isVehicle`: SetPassengers names its first rider.
|
||||
Ridden => flag(m.extra.rider.is_some() || m.rider.is_some()),
|
||||
Climbing => flag(m.climbing),
|
||||
Jumping => flag(!m.on_ground && m.velocity.y > 0.0),
|
||||
Paused => flag(frame.paused),
|
||||
@@ -611,14 +637,32 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
|
||||
ThirdPerson => flag(!frame.first_person && !frame.third_person_reversed),
|
||||
ThirdPersonReversed => flag(frame.third_person_reversed),
|
||||
RightHanded => flag(!m.main_arm_left()),
|
||||
Sitting => flag(m.extra.sitting),
|
||||
// The tamed animals' DATA_FLAGS_ID, and the species that keep their own.
|
||||
Sitting => flag(match m.species() {
|
||||
super::Species::Fox => super::fox::flag(m, super::fox::SITTING),
|
||||
super::Species::Panda => super::panda::is_sitting(m),
|
||||
super::Species::Camel | super::Species::CamelHusk => super::camel::sitting(m),
|
||||
_ => m.extra.sitting,
|
||||
}),
|
||||
Tamed => flag(m.extra.tame),
|
||||
Sprinting => flag(m.extra.sprinting),
|
||||
Sneaking => flag(m.extra.crouching),
|
||||
// Not known to the draw yet: 0 until something reads them.
|
||||
InHand | InItemFrame | InGround | OnHead | OnShoulder | Ridden | Blocking
|
||||
| Crawling | Swimming | SwingingRight | SwingingLeft | FirstPersonHand | UsingItem
|
||||
| HoldingRight | HoldingLeft | Hovered => 0.0,
|
||||
// A player's `isBlocking`: the item in use blocks attacks.
|
||||
Blocking => flag(m.extra.blocking),
|
||||
// `isVisuallyCrawling`: the swimming pose out of water.
|
||||
Crawling => flag(swim_pose && !m.fluids.in_water),
|
||||
Swimming => flag(swim_pose),
|
||||
// DATA_LIVING_ENTITY_FLAGS' using bit (the client counts the ticks).
|
||||
UsingItem => flag(m.extra.using_item > 0),
|
||||
SwingingRight => flag(swinging && swing_right),
|
||||
SwingingLeft => flag(swinging && !swing_right),
|
||||
HoldingRight => flag(right_hand.is_some()),
|
||||
HoldingLeft => flag(left_hand.is_some()),
|
||||
// Where the model is drawn (a held, framed or worn item, a shoulder): the draw
|
||||
// doesn't pass it yet, so 0 until something reads them.
|
||||
InHand | InItemFrame | InGround | OnHead | OnShoulder | FirstPersonHand | Hovered => {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -882,18 +926,68 @@ mod tests {
|
||||
m.extra.crouching = true;
|
||||
// The player's main hand option set to left.
|
||||
m.extra.main_hand_left = true;
|
||||
m.extra.blocking = true;
|
||||
// The swimming pose out of water: crawling.
|
||||
m.extra.pose = 3;
|
||||
m.extra.using_item = 3;
|
||||
// Only the main hand holds something, and it is the left one.
|
||||
m.extra.set_main_hand(Some("minecraft:stick".into()));
|
||||
// A swing that names no hand moves the main arm: the left.
|
||||
m.ai.swinging = true;
|
||||
m.extra.rider = Some(7);
|
||||
let model = bone("root", vec![]);
|
||||
let animation = animate(
|
||||
&model,
|
||||
&[(
|
||||
"root.tx",
|
||||
"is_aggressive + is_sitting * 2 + is_tamed * 4 + is_sprinting * 8 \
|
||||
+ is_sneaking * 16 + is_right_handed * 32",
|
||||
+ is_sneaking * 16 + is_right_handed * 32 + is_blocking * 64 \
|
||||
+ is_crawling * 128 + is_swimming * 256 + is_using_item * 512 \
|
||||
+ is_holding_item_right * 1024 + is_holding_item_left * 2048 \
|
||||
+ is_swinging_right_arm * 4096 + is_swinging_left_arm * 8192 \
|
||||
+ is_ridden * 16384",
|
||||
)],
|
||||
);
|
||||
let mut poses = Poses::new(&model);
|
||||
animation.run_in(&Frame::default(), &m, &still(), &mut poses);
|
||||
assert_eq!(poses.get("root")[0], 31.0);
|
||||
assert_eq!(
|
||||
poses.get("root")[0],
|
||||
(31 + 64 + 128 + 256 + 512 + 2048 + 8192 + 16384) as f32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_swimming_pose_in_water_swims_without_crawling() {
|
||||
let mut m = mob();
|
||||
m.extra.pose = 3;
|
||||
let model = bone("root", vec![]);
|
||||
let animation = animate(&model, &[("root.tx", "is_swimming + is_crawling * 2")]);
|
||||
let mut read = |in_water: bool| {
|
||||
m.fluids.in_water = in_water;
|
||||
let mut poses = Poses::new(&model);
|
||||
animation.run_in(&Frame::default(), &m, &still(), &mut poses);
|
||||
poses.get("root")[0]
|
||||
};
|
||||
assert_eq!([read(true), read(false)], [1.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fox_sits_by_its_own_flags() {
|
||||
let mut m = mob();
|
||||
m.kind = std::sync::Arc::new(crate::mobs::MobType {
|
||||
species: crate::mobs::Species::Fox,
|
||||
..(*m.kind).clone()
|
||||
});
|
||||
let model = bone("root", vec![]);
|
||||
let animation = animate(&model, &[("root.tx", "is_sitting")]);
|
||||
let mut sitting = |flags: i32| {
|
||||
m.extra.ints[0] = flags;
|
||||
let mut poses = Poses::new(&model);
|
||||
animation.run_in(&Frame::default(), &m, &still(), &mut poses);
|
||||
poses.get("root")[0]
|
||||
};
|
||||
assert_eq!(sitting(crate::mobs::fox::SITTING), 1.0);
|
||||
assert_eq!(sitting(crate::mobs::fox::CROUCHING), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -345,25 +345,28 @@ pub fn work_sound(profession: u8) -> Option<&'static str> {
|
||||
}
|
||||
/// A `CropBlock` at `p`.
|
||||
pub fn crop(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
matches!(
|
||||
w.block_name(p[0], p[1], p[2]),
|
||||
"minecraft:wheat"
|
||||
| "minecraft:carrots"
|
||||
| "minecraft:potatoes"
|
||||
| "minecraft:beetroots"
|
||||
| "minecraft:torchflower_crop"
|
||||
)
|
||||
w.facts
|
||||
.get(w.world.block(p[0], p[1], p[2]))
|
||||
.has_class("CropBlock")
|
||||
}
|
||||
/// `CropBlock.isMaxAge` for the block at `p`.
|
||||
pub fn ripe_crop(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
let max = match w.block_name(p[0], p[1], p[2]) {
|
||||
"minecraft:wheat" | "minecraft:carrots" | "minecraft:potatoes" => "7",
|
||||
"minecraft:beetroots" => "3",
|
||||
// TorchflowerCropBlock.getMaxAge is 2, past its age property (0-1):
|
||||
// the crop is never at max age (age 2 is the torchflower itself).
|
||||
_ => return false,
|
||||
let facts = w.facts.get(w.world.block(p[0], p[1], p[2]));
|
||||
if !facts.has_class("CropBlock") {
|
||||
return false;
|
||||
}
|
||||
// The class chain survives content registration, including the two
|
||||
// getMaxAge overrides. PitcherCropBlock is not a CropBlock.
|
||||
let max = if facts.has_class("BeetrootBlock") {
|
||||
3
|
||||
} else if facts.has_class("TorchflowerCropBlock") {
|
||||
2
|
||||
} else {
|
||||
7
|
||||
};
|
||||
w.prop(p[0], p[1], p[2], "age") == Some(max)
|
||||
w.prop(p[0], p[1], p[2], "age")
|
||||
.and_then(|age| age.parse::<i32>().ok())
|
||||
.is_some_and(|age| age >= max)
|
||||
}
|
||||
|
||||
/// The gift loot table of a villager (`GiveGiftToHero.getLootTableToThrow`).
|
||||
|
||||
@@ -775,7 +775,7 @@ fn sense_babies(m: &mut Mob, w: &MobWorld) {
|
||||
}
|
||||
mem(m).visible_villager_babies.set_list(babies);
|
||||
}
|
||||
/// `SecondaryPoiSensor`: a farmer's farmland within four blocks.
|
||||
/// `SecondaryPoiSensor.doTick`: a farmer's farmland within four blocks.
|
||||
fn sense_secondary(m: &mut Mob, w: &MobWorld) {
|
||||
let mut sites = Vec::new();
|
||||
if super::villager::is_farmer(m) {
|
||||
@@ -784,7 +784,7 @@ fn sense_secondary(m: &mut Mob, w: &MobWorld) {
|
||||
for y in -2..=2 {
|
||||
for z in -4..=4 {
|
||||
let p = [c[0] + x, c[1] + y, c[2] + z];
|
||||
if w.block_name(p[0], p[1], p[2]) == "minecraft:farmland" {
|
||||
if farmland(w, p) {
|
||||
sites.push(p);
|
||||
}
|
||||
}
|
||||
@@ -3494,6 +3494,10 @@ fn restock_if_due(m: &mut Mob, w: &MobWorld) {
|
||||
t.restock(w.game_time);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "villager_farming_tests.rs"]
|
||||
mod farming_tests;
|
||||
|
||||
/// `HarvestFarmland.validFarmlandAroundVillager`: ripe crops and bare
|
||||
/// farmland around the villager.
|
||||
fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
|
||||
@@ -3514,10 +3518,16 @@ fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
|
||||
}
|
||||
valid
|
||||
}
|
||||
/// Farmland subclasses include mod templates.
|
||||
fn farmland(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
w.facts
|
||||
.get(w.world.block(p[0], p[1], p[2]))
|
||||
.has_class("FarmlandBlock")
|
||||
}
|
||||
/// `HarvestFarmland.validPos`: a ripe CropBlock or air above farmland.
|
||||
fn farmland_valid(w: &MobWorld, p: [i32; 3]) -> bool {
|
||||
super::villager::ripe_crop(w, p)
|
||||
|| w.facts.is_air(w.world.block(p[0], p[1], p[2]))
|
||||
&& w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland"
|
||||
|| w.facts.is_air(w.world.block(p[0], p[1], p[2])) && farmland(w, [p[0], p[1] - 1, p[2]])
|
||||
}
|
||||
/// `HarvestFarmland.getValidFarmland`: one picked by the level random.
|
||||
fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> {
|
||||
@@ -3527,17 +3537,13 @@ fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> {
|
||||
let i = cx.lr.next_int_bound(valid.len() as i32).unwrap_or(0) as usize;
|
||||
Some(valid[i])
|
||||
}
|
||||
/// The seeds a villager plants (`VILLAGER_PLANTABLE_SEEDS`) and their crops.
|
||||
fn seed_crop(item: &str) -> Option<&'static str> {
|
||||
Some(match item {
|
||||
"minecraft:wheat_seeds" => "minecraft:wheat",
|
||||
"minecraft:potato" => "minecraft:potatoes",
|
||||
"minecraft:carrot" => "minecraft:carrots",
|
||||
"minecraft:beetroot_seeds" => "minecraft:beetroots",
|
||||
"minecraft:torchflower_seeds" => "minecraft:torchflower_crop",
|
||||
"minecraft:pitcher_pod" => "minecraft:pitcher_crop",
|
||||
_ => return None,
|
||||
})
|
||||
/// A tagged BlockItem plants its block's default state.
|
||||
fn seed_crop(w: &MobWorld, item: &str) -> Option<horizon_world::registry::BlockStateId> {
|
||||
let items = w.items?;
|
||||
if !items.item_in_tag("minecraft:villager_plantable_seeds", item) {
|
||||
return None;
|
||||
}
|
||||
items.get(item)?.block
|
||||
}
|
||||
/// `HarvestFarmland.tick` at the farmland: harvest a ripe crop, sow bare
|
||||
/// farmland, and move on from a growing crop.
|
||||
@@ -3559,23 +3565,22 @@ fn harvest_tick(
|
||||
if ripe {
|
||||
cx.events.push(MobEvent::BreakBlock { pos: p, drop: true });
|
||||
}
|
||||
if air && w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland" {
|
||||
if air && farmland(w, [p[0], p[1] - 1, p[2]]) {
|
||||
let e = super::villager::ext_mut(m);
|
||||
if let Some(slot) = e
|
||||
if let Some((slot, crop)) = e
|
||||
.inventory
|
||||
.iter()
|
||||
.position(|s| s.count > 0 && seed_crop(&s.item).is_some())
|
||||
.enumerate()
|
||||
.filter(|(_, s)| s.count > 0)
|
||||
.find_map(|(i, s)| seed_crop(w, &s.item).map(|crop| (i, crop)))
|
||||
{
|
||||
let crop = seed_crop(&e.inventory[slot].item).unwrap_or("minecraft:wheat");
|
||||
e.inventory[slot].count -= 1;
|
||||
if e.inventory[slot].count == 0 {
|
||||
e.inventory.remove(slot);
|
||||
}
|
||||
cx.events.push(MobEvent::SetBlockName {
|
||||
cx.events.push(MobEvent::SetBlock {
|
||||
pos: p,
|
||||
name: crop,
|
||||
effect: false,
|
||||
flags: horizon_world::level::flags::UPDATE_ALL,
|
||||
state: crop,
|
||||
});
|
||||
cx.events.push(MobEvent::Sound(
|
||||
"item.crop.plant",
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
use super::*;
|
||||
use crate::{items::ItemStack, mod_content::ContentMod};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
fn resources() -> &'static std::path::Path {
|
||||
static ROOT: OnceLock<PathBuf> = OnceLock::new();
|
||||
ROOT.get_or_init(|| {
|
||||
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
let scratch = std::env::temp_dir().join(format!("fresh-farming-{}", std::process::id()));
|
||||
let dir = scratch.join("mod");
|
||||
let mut files = vec![(
|
||||
"horizon-mod.toml".to_owned(),
|
||||
"id = \"fieldtest\"\nname = \"Field test\"\nversion = \"1.0\"\napi = \"0.2\"\nside = \"both\"\ncapabilities = [\"registries\"]\n".to_owned(),
|
||||
)];
|
||||
for (id, template) in [
|
||||
("soil", "farmland"),
|
||||
("grain", "wheat"),
|
||||
("roots", "beetroots"),
|
||||
("flower", "torchflower_crop"),
|
||||
("pod", "pitcher_crop"),
|
||||
("fake_crop", "stone"),
|
||||
] {
|
||||
files.push((
|
||||
format!("data/fieldtest/horizon/block/{id}.json"),
|
||||
format!(r#"{{"template":"minecraft:{template}"}}"#),
|
||||
));
|
||||
}
|
||||
files.push((
|
||||
"data/minecraft/tags/item/villager_plantable_seeds.json".into(),
|
||||
r#"{"replace":true,"values":["fieldtest:grain","fieldtest:roots","minecraft:stick"]}"#.into(),
|
||||
));
|
||||
for (name, text) in files {
|
||||
let path = dir.join(name);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
let package = horizon_mods::Package::read(&dir).unwrap();
|
||||
let content = horizon_mods::Content::read(&dir, &package.manifest).unwrap();
|
||||
let out = scratch.join("resources");
|
||||
crate::mod_content::build(&base, &[ContentMod { id: "fieldtest".into(), content }], &out)
|
||||
.unwrap();
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
fn farmer(types: &super::super::MobTypes) -> Mob {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(types.types["minecraft:villager"].clone()),
|
||||
Vec3::new(0.5, 1.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
super::super::villager::set_profession(&mut m, 5);
|
||||
m
|
||||
}
|
||||
|
||||
fn harvest() -> B {
|
||||
B::HarvestFarmland {
|
||||
t: timed(60, 60),
|
||||
next_ok: 0,
|
||||
target: None,
|
||||
valid: Vec::new(),
|
||||
worked: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn put(
|
||||
world: &mut crate::world::World,
|
||||
x: i32,
|
||||
y: i32,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) {
|
||||
if !world.chunks.contains_key(&(x >> 4, 0)) {
|
||||
world
|
||||
.chunks
|
||||
.insert((x >> 4, 0), Arc::new(crate::chunk::Chunk::empty(x >> 4, 0)));
|
||||
}
|
||||
world.set(x, y, 0, state).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
|
||||
fn mod_farmland_is_a_secondary_site_and_farming_target() {
|
||||
assert!(super::super::world::with_resource_test_world(
|
||||
resources(),
|
||||
&[],
|
||||
true,
|
||||
|world, reg| {
|
||||
put(world, 0, 0, reg.default_state("fieldtest:soil").unwrap());
|
||||
},
|
||||
|w, types| {
|
||||
let mut m = farmer(types);
|
||||
sense_secondary(&mut m, w);
|
||||
assert_eq!(
|
||||
mem(&mut m).secondary_job_site.value(),
|
||||
Some(vec![[0, 0, 0]])
|
||||
);
|
||||
let mut lr = LegacyRandom::new(11);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Bx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
now: 1000,
|
||||
refresh: false,
|
||||
};
|
||||
assert!(harvest().try_start(&mut m, &mut cx));
|
||||
let mut denied = w.clone();
|
||||
denied.mob_griefing = false;
|
||||
mem(&mut m).look_target.clear();
|
||||
mem(&mut m).walk_target.clear();
|
||||
cx.w = &denied;
|
||||
assert!(!harvest().try_start(&mut m, &mut cx));
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
|
||||
fn mod_crops_are_harvested_only_at_their_vanilla_templates_max_age() {
|
||||
assert!(super::super::world::with_resource_test_world(
|
||||
resources(),
|
||||
&[],
|
||||
true,
|
||||
|world, reg| {
|
||||
for (x, block, age) in [
|
||||
(0, "grain", "6"),
|
||||
(1, "grain", "7"),
|
||||
(2, "roots", "3"),
|
||||
(3, "flower", "1"),
|
||||
(4, "pod", "4"),
|
||||
] {
|
||||
let mut state = reg
|
||||
.state(reg.default_state(&format!("fieldtest:{block}")).unwrap())
|
||||
.unwrap()
|
||||
.clone();
|
||||
state.properties.insert("age".into(), age.into());
|
||||
put(world, x, 1, reg.resolve(&state).unwrap());
|
||||
}
|
||||
put(
|
||||
world,
|
||||
5,
|
||||
1,
|
||||
reg.default_state("fieldtest:fake_crop").unwrap(),
|
||||
);
|
||||
},
|
||||
|w, types| {
|
||||
let mut m = farmer(types);
|
||||
let mut lr = LegacyRandom::new(12);
|
||||
let mut events = Vec::new();
|
||||
for (x, ripe) in [
|
||||
(0, false),
|
||||
(1, true),
|
||||
(2, true),
|
||||
(3, false),
|
||||
(4, false),
|
||||
(5, false),
|
||||
] {
|
||||
events.clear();
|
||||
harvest_tick(
|
||||
&mut m,
|
||||
&mut Bx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
now: 1000,
|
||||
refresh: false,
|
||||
},
|
||||
&mut 0,
|
||||
&mut Some([x, 1, 0]),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
assert_eq!(events.iter().any(|e| matches!(e, MobEvent::BreakBlock { pos, drop: true } if *pos == [x, 1, 0])), ripe, "x={x}");
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
|
||||
fn farming_uses_the_first_tagged_block_item_and_consumes_one_seed() {
|
||||
assert!(super::super::world::with_resource_test_world(
|
||||
resources(),
|
||||
&[],
|
||||
true,
|
||||
|world, reg| {
|
||||
for x in 0..=1 {
|
||||
put(world, x, 0, reg.default_state("fieldtest:soil").unwrap());
|
||||
}
|
||||
},
|
||||
|w, types| {
|
||||
let mut m = farmer(types);
|
||||
let inventory = &mut super::super::villager::ext_mut(&mut m).inventory;
|
||||
*inventory = vec![
|
||||
ItemStack::new("minecraft:wheat_seeds", 2),
|
||||
ItemStack::new("minecraft:stick", 2),
|
||||
ItemStack::new("fieldtest:flower", 2),
|
||||
ItemStack::new("fieldtest:roots", 1),
|
||||
ItemStack::new("fieldtest:grain", 2),
|
||||
];
|
||||
let mut lr = LegacyRandom::new(13);
|
||||
let mut events = Vec::new();
|
||||
harvest_tick(
|
||||
&mut m,
|
||||
&mut Bx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
now: 1000,
|
||||
refresh: false,
|
||||
},
|
||||
&mut 0,
|
||||
&mut Some([0, 1, 0]),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
let planted = events.iter().find_map(|e| match e {
|
||||
MobEvent::SetBlock { state, .. } => {
|
||||
Some(w.registry.state(*state).unwrap().name.as_str())
|
||||
}
|
||||
MobEvent::SetBlockName { name, .. } => Some(*name),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(planted, Some("fieldtest:roots"));
|
||||
let inventory = &super::super::villager::ext(&m).unwrap().inventory;
|
||||
assert_eq!(
|
||||
inventory
|
||||
.iter()
|
||||
.map(|s| (&*s.item, s.count))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("minecraft:wheat_seeds", 2),
|
||||
("minecraft:stick", 2),
|
||||
("fieldtest:flower", 2),
|
||||
("fieldtest:grain", 2)
|
||||
]
|
||||
);
|
||||
events.clear();
|
||||
harvest_tick(
|
||||
&mut m,
|
||||
&mut Bx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
now: 1001,
|
||||
refresh: false,
|
||||
},
|
||||
&mut 0,
|
||||
&mut Some([1, 1, 0]),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
let expected = w.registry.default_state("fieldtest:grain").unwrap();
|
||||
assert!(events.iter().any(
|
||||
|e| matches!(e, MobEvent::SetBlock { pos: [1, 1, 0], state } if *state == expected)
|
||||
));
|
||||
assert_eq!(
|
||||
super::super::villager::ext(&m)
|
||||
.unwrap()
|
||||
.inventory
|
||||
.last()
|
||||
.unwrap()
|
||||
.count,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, MobEvent::Sound("item.crop.plant", _, 1.0, 1.0)))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "tools/test_villager_farming_parity.py supplies the vanilla oracle"]
|
||||
fn vanilla_farming_classification_matches_the_oracle() {
|
||||
let path = std::env::var_os("HORIZON_FARMING_ORACLE").expect("vanilla oracle output");
|
||||
let rows: Vec<(u32, Vec<bool>)> = std::fs::read_to_string(path)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let mut fields = line.split_whitespace();
|
||||
(
|
||||
fields.next().unwrap().parse().unwrap(),
|
||||
fields.map(|f| f.parse().unwrap()).collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert!(!rows.is_empty());
|
||||
assert!(super::super::world::with_built_test_world(
|
||||
&[],
|
||||
|world, _| {
|
||||
for (x, (state, _)) in rows.iter().enumerate() {
|
||||
put(
|
||||
world,
|
||||
x as i32,
|
||||
0,
|
||||
horizon_world::registry::BlockStateId(*state),
|
||||
);
|
||||
}
|
||||
},
|
||||
|w, _| {
|
||||
for (x, (state, expected)) in rows.iter().enumerate() {
|
||||
let p = [x as i32, 0, 0];
|
||||
assert_eq!(
|
||||
vec![
|
||||
super::super::villager::crop(w, p),
|
||||
super::super::villager::ripe_crop(w, p),
|
||||
farmland_valid(w, [p[0], 1, 0])
|
||||
],
|
||||
*expected,
|
||||
"state={state}"
|
||||
);
|
||||
}
|
||||
}
|
||||
));
|
||||
}
|
||||
@@ -417,24 +417,35 @@ pub(crate) fn with_built_test_world_rules(
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
with_resource_test_world(&root, others, mob_griefing, build, f)
|
||||
}
|
||||
/// The same mob world loaded from a content mod's resource root.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_resource_test_world(
|
||||
root: &std::path::Path,
|
||||
others: &[Seen],
|
||||
mob_griefing: bool,
|
||||
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
|
||||
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
|
||||
) -> bool {
|
||||
if !root.join("entity-types.json").exists() || !root.join("movement-properties.nbt").exists() {
|
||||
return false;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let assets = crate::assets::Assets::load(root).unwrap();
|
||||
let registry = assets.registry;
|
||||
let facts = BlockFacts::load(&root, registry).unwrap();
|
||||
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
|
||||
let data = super::MobData::load(&root, &facts);
|
||||
let types = super::MobTypes::load(&root).unwrap();
|
||||
let biomes = crate::biome::Biomes::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(root, registry).unwrap();
|
||||
let motion = crate::motion::MotionResources::load(root, &assets).unwrap();
|
||||
let data = super::MobData::load(root, &facts);
|
||||
let types = super::MobTypes::load(root).unwrap();
|
||||
let biomes = crate::biome::Biomes::load(root).unwrap();
|
||||
let items = crate::items::Items::load(
|
||||
&root,
|
||||
root,
|
||||
registry,
|
||||
&facts,
|
||||
&horizon_world::loot::PackOverlay::NONE,
|
||||
)
|
||||
.unwrap();
|
||||
let collision = crate::collisions::load(&root, registry).unwrap();
|
||||
let collision = crate::collisions::load(root, registry).unwrap();
|
||||
let air = registry.default_state("minecraft:air").unwrap();
|
||||
let mut world = crate::world::World::new(air, 0);
|
||||
build(&mut world, registry);
|
||||
|
||||
+2
-1
@@ -1144,7 +1144,8 @@ pub trait Hooks: Send + Sync {
|
||||
/// the clicked position, whether the clicked block is being replaced, the
|
||||
/// item in hand) and `fluid` the fluid in the position the block would go
|
||||
/// in, which a waterlogged block reads. Build the state from `state` with
|
||||
/// `native_state_with_property`. It is asked for every placer that goes
|
||||
/// `native_state_with_property`, and know whether `state` is yours with
|
||||
/// `native_state_block`. It is asked for every placer that goes
|
||||
/// through vanilla's placement (a player's `BlockItem.place`, a
|
||||
/// dispenser's `DirectionalPlaceContext`), and the answer still takes its
|
||||
/// survival and entity obstruction checks. A trait mod returns
|
||||
|
||||
@@ -344,6 +344,10 @@ pub struct PlayerMotion {
|
||||
pub local_crouching: bool,
|
||||
pub pose: crate::pose::Pose,
|
||||
pub dimensions: crate::pose::Dimensions,
|
||||
/// The `minecraft:scale` the dimensions were last built with
|
||||
/// (`Entity.refreshDimensions`), for the poses the body takes before it
|
||||
/// next refreshes.
|
||||
pub scale: f32,
|
||||
pub speed: f32,
|
||||
/// `LocalPlayer.itemUseSpeedMultiplier` while using an item (not riding).
|
||||
pub item_use_speed: Option<f32>,
|
||||
@@ -428,6 +432,16 @@ fn attribute_error(error: impl std::fmt::Debug) -> io::Error {
|
||||
io::Error::other(format!("movement attribute: {error:?}"))
|
||||
}
|
||||
impl PlayerMotion {
|
||||
/// `ServerGamePacketListenerImpl.handleMovePlayer`: the movement the client
|
||||
/// sent is the server copy's own delta movement. A glide's wall hit reads it
|
||||
/// as the speed the glide had (`LivingEntity.handleFallFlyingCollisions`'s
|
||||
/// moveHorLength); a copy whose travels start from its own last velocity
|
||||
/// otherwise keeps a few hundredths there, and no hit reaches the damage.
|
||||
pub fn carry_client_movement(&mut self, movement: Vec3) {
|
||||
if self.fall_flying {
|
||||
self.velocity = movement;
|
||||
}
|
||||
}
|
||||
/// `Player.isSweepAttack`, using the resident player's known horizontal velocity.
|
||||
pub fn is_sweep_attack(&self, strength: f32, critical: bool, sword: bool) -> bool {
|
||||
let movement = self.velocity;
|
||||
@@ -559,6 +573,7 @@ impl PlayerMotion {
|
||||
local_crouching: false,
|
||||
pose: crate::pose::Pose::Standing,
|
||||
dimensions: crate::pose::Pose::Standing.dimensions(1.0),
|
||||
scale: 1.0,
|
||||
swimming: false,
|
||||
is_passenger: false,
|
||||
fire_ticks: -20,
|
||||
@@ -717,6 +732,18 @@ impl PlayerMotion {
|
||||
/// player's `attributes` list (LivingEntity.addAdditionalSaveData), each
|
||||
/// instance's id, base and permanent modifiers.
|
||||
pub fn saved_attributes(&self) -> horizon_world::nbt::Tag {
|
||||
self.packed_attributes(true)
|
||||
}
|
||||
/// `ServerPlayer.restoreFrom`: the attributes a player's new body takes
|
||||
/// over from the old one. A body that comes back from a death takes the
|
||||
/// base values only (`assignBaseValues`); the same player in another
|
||||
/// level (`ServerPlayer.teleport`) is the same entity, with its permanent
|
||||
/// modifiers too (`restoreAll`, `assignPermanentModifiers`). A scale of
|
||||
/// 0.1 is still 0.1 after a respawn or a trip through a portal (#3684).
|
||||
pub fn carried_attributes(&self, restore_all: bool) -> horizon_world::nbt::Tag {
|
||||
self.packed_attributes(restore_all)
|
||||
}
|
||||
fn packed_attributes(&self, modifiers: bool) -> horizon_world::nbt::Tag {
|
||||
use horizon_client_core::nbt_value::string;
|
||||
use horizon_world::nbt::Tag;
|
||||
let list = self
|
||||
@@ -729,7 +756,7 @@ impl PlayerMotion {
|
||||
("base", Tag::Double(packed.base.to_bits())),
|
||||
];
|
||||
// `modifiers` is an optional field whose default is the empty list.
|
||||
if !packed.modifiers.is_empty() {
|
||||
if modifiers && !packed.modifiers.is_empty() {
|
||||
let modifiers = packed
|
||||
.modifiers
|
||||
.iter()
|
||||
@@ -1035,9 +1062,9 @@ impl MotionWorld<'_, '_, '_> {
|
||||
if self.player.spectator {
|
||||
self.player.flying = true;
|
||||
self.player.ground.on_ground = false;
|
||||
} else if !self.player.may_fly {
|
||||
self.player.flying = false;
|
||||
} else if !was_jumping && jumping && !self.player.was_auto_jump {
|
||||
} else if self.player.may_fly && !was_jumping && jumping && !self.player.was_auto_jump {
|
||||
// `LocalPlayer.aiStep`: mayfly gates the flight toggle, not the
|
||||
// flying state independently supplied by the server's abilities.
|
||||
if self.player.jump_trigger_time == 0 {
|
||||
self.player.jump_trigger_time = 7;
|
||||
} else if !self.player.swimming {
|
||||
@@ -3430,6 +3457,60 @@ mod tests {
|
||||
assert!(player.events.is_empty());
|
||||
}
|
||||
|
||||
/// `LivingEntity.handleFallFlyingCollisions`: a glide that meets a wall loses
|
||||
/// the horizontal speed it had, and the server's copy hurts by that loss,
|
||||
/// `(moveHorLength - newMoveHorLength) * 10 - 3`, as `minecraft:fly_into_wall`.
|
||||
/// The speed it starts the tick with is the client's own movement
|
||||
/// (`handleMovePlayer` gives the server copy its delta movement), so the
|
||||
/// loss is the speed the glide actually flew at.
|
||||
#[test]
|
||||
fn a_glide_into_a_wall_hurts_by_the_speed_it_lost() {
|
||||
let root = 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 = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
// A wall in the way of a glide along +x, at feet height.
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
for y in 4..=5 {
|
||||
for z in 19..=21 {
|
||||
scene.set(23, y, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(22.0, 4.0, 20.5)).unwrap();
|
||||
player.fall_flying = true;
|
||||
// Looking along +x (the `getLookAngle` of a flat -90 yaw) keeps the
|
||||
// glide on its course through `updateFallFlyingMovement`'s pull.
|
||||
player.yaw = -90.0;
|
||||
player.pitch = 0.0;
|
||||
player.carry_client_movement(Vec3::new(1.2, 0.0, 0.0));
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.server_travel()
|
||||
.unwrap();
|
||||
assert!(
|
||||
player
|
||||
.hurt_log
|
||||
.last()
|
||||
.is_some_and(|hurt| hurt.source == "minecraft:fly_into_wall"),
|
||||
"{:?}",
|
||||
player.hurt_log
|
||||
);
|
||||
assert!(player.health.health() < 20.0, "{}", player.health.health());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectator_scroll_speed_controls_horizontal_and_vertical_flight() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -5166,6 +5247,60 @@ mod tests {
|
||||
assert!(w.player.sprinting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_forced_flight_survives_without_permission_to_toggle_it() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
eprintln!("skipped forced flight: prepared resources unavailable");
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut player = PlayerMotion::new(&resources, start).unwrap();
|
||||
player.may_fly = false;
|
||||
player.flying = true;
|
||||
player.flying_speed = 0.0;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
// Idle, movement in all directions, and repeated jump presses must
|
||||
// neither cancel server-forced flight nor move a zero-speed flyer.
|
||||
for tick in 0..60 {
|
||||
w.controlled_simulation_tick(crate::sprint::Keys {
|
||||
forward: tick % 4 == 0,
|
||||
backward: tick % 4 == 1,
|
||||
left: tick % 4 == 2,
|
||||
right: tick % 4 == 3,
|
||||
jump: tick % 2 == 0,
|
||||
shift: tick % 3 == 0,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(w.player.flying, "server-forced flight ended on tick {tick}");
|
||||
assert_eq!(w.player.position, start);
|
||||
assert_eq!(w.player.velocity, Vec3::ZERO);
|
||||
}
|
||||
// A later abilities update from the server can still end flight.
|
||||
w.player.flying = false;
|
||||
for _ in 0..3 {
|
||||
w.controlled_simulation_tick(crate::sprint::Keys::default())
|
||||
.unwrap();
|
||||
}
|
||||
assert!(!w.player.flying);
|
||||
assert!(w.player.position.y < start.y);
|
||||
}
|
||||
|
||||
/// Vanilla `Player.getFlyingSpeed`: a flying player moves at the ability
|
||||
/// speed, doubled while the sprint state holds. The sprint key starts the
|
||||
/// state; releasing it does not change the speed.
|
||||
|
||||
@@ -94,7 +94,8 @@ impl PlayerMotion {
|
||||
Ok(pose.dimensions(self.attribute("minecraft:scale")? as f32))
|
||||
}
|
||||
pub fn refresh_dimensions(&mut self) -> Result<()> {
|
||||
self.dimensions = self.dimensions_for_pose(self.pose)?;
|
||||
self.scale = self.attribute("minecraft:scale")? as f32;
|
||||
self.dimensions = self.pose.dimensions(self.scale);
|
||||
Ok(())
|
||||
}
|
||||
pub fn set_pose(&mut self, pose: Pose) -> Result<()> {
|
||||
|
||||
@@ -470,7 +470,8 @@ pub(crate) struct Button {
|
||||
enabled: bool,
|
||||
id: u8,
|
||||
icon: Option<(&'static str, f32)>,
|
||||
/// `SpriteIconButton.TextAndIcon`: the icon at the right end, the text centred.
|
||||
/// `SpriteIconButton.TextAndIcon`: the icon at the right end, the text
|
||||
/// centred in the room left of it.
|
||||
trailing: Option<(&'static str, f32)>,
|
||||
}
|
||||
impl Button {
|
||||
@@ -479,17 +480,36 @@ impl Button {
|
||||
/// (`AbstractWidget.extractScrollingStringOverContents`), instead of being
|
||||
/// drawn past it over the buttons beside it (so_so's 590 px World Type
|
||||
/// label on Create World, hi_in's Join Server in the Multiplayer footer).
|
||||
/// A button with a `trailing` icon is `SpriteIconButton.TextAndIcon`, whose
|
||||
/// label keeps clear of the icon: see [`Button::draw_label_at`].
|
||||
fn draw_label(&self, batch: &mut HudBatch, atlas: &HudAtlas, color: u32) {
|
||||
let label = Text::literal(&self.label);
|
||||
crate::ui::scrolling_text(
|
||||
self.draw_label_at(batch, atlas, color, crate::hud_overlays::now_ms());
|
||||
}
|
||||
|
||||
/// [`Button::draw_label`] at `now_ms`. `SpriteIconButton.TextAndIcon`
|
||||
/// (`extractContents`) hands the label to `acceptScrolling` with `left`
|
||||
/// at the button's x plus 2, `right` at its x plus width minus the
|
||||
/// sprite's width minus 4, and `centerX` at its x plus half its width: the
|
||||
/// label slides inside that box when wider, and one that fits is moved
|
||||
/// left until it ends where the icon's room begins. A button without an
|
||||
/// icon has `right` 2 short of its edge, where `centerX` is the middle of
|
||||
/// the box and nothing moves.
|
||||
fn draw_label_at(&self, batch: &mut HudBatch, atlas: &HudAtlas, color: u32, now_ms: i64) {
|
||||
let right = match self.trailing {
|
||||
Some((_, size)) => self.x + self.w - size - 4.0,
|
||||
None => self.x + self.w - 2.0,
|
||||
};
|
||||
crate::ui::scrolling_text_around_at(
|
||||
batch,
|
||||
atlas,
|
||||
&label,
|
||||
&Text::literal(&self.label),
|
||||
self.x + (self.w / 2.0).floor(),
|
||||
self.x + 2.0,
|
||||
self.x + self.w - 2.0,
|
||||
right,
|
||||
self.y,
|
||||
self.y + 20.0,
|
||||
self.y + 6.0,
|
||||
now_ms,
|
||||
color,
|
||||
);
|
||||
}
|
||||
|
||||
Loaded 100 of 191 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user