mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Refresh crafter map branch while preserving shared item-use fixtures
Retain map metadata and max-scale controls alongside current fixture groups and explicit boat-use packet actions. Co-Authored-By: Codex <[email protected]>
This commit is contained in:
commit
123d271505
441 files changed
+30242
-1980
No files matched your search
@@ -143,7 +143,9 @@ impl<'a> Fields<'a> {
|
||||
/// `RandomizableContainer.tryLoadLootTable` / `trySaveLootTable`; true when the
|
||||
/// block entity has a loot table (and so saves no items).
|
||||
fn loot_table(&mut self) -> bool {
|
||||
let Some(table) = text(self.input.get("LootTable")) else {
|
||||
let Some(table) =
|
||||
text(self.input.get("LootTable")).and_then(|id| crate::identifier::normalize_id(&id))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
self.put("LootTable", string(&table));
|
||||
@@ -905,6 +907,16 @@ mod tests {
|
||||
assert_eq!(out.get("LootTable"), Some(&string("minecraft:chests/x")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_loot_table_leaves_items_intact() {
|
||||
let out = canon(
|
||||
"{id:\"minecraft:barrel\",x:1,y:2,z:3,LootTable:\"INVALID TABLE\",\
|
||||
Items:[{Slot:0b,id:\"minecraft:stone\",count:1}]}",
|
||||
);
|
||||
assert!(out.get("Items").is_some());
|
||||
assert!(out.get("LootTable").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_are_validated_and_sorted_by_slot() {
|
||||
let out = canon(
|
||||
|
||||
@@ -207,9 +207,11 @@ fn style_flag(v: Option<&Value>) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
fn untrusted_http_url(url: &str) -> bool {
|
||||
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
|
||||
// https only, scheme case-insensitive.
|
||||
/// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
|
||||
/// https only, scheme case-insensitive. Every `open_url` a server sends must
|
||||
/// pass it: anything else (`file:`, a UNC path, a custom protocol handler)
|
||||
/// could start a program through the system's URL handler.
|
||||
pub fn untrusted_http_url(url: &str) -> bool {
|
||||
url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
|
||||
@@ -388,6 +388,36 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Language.loadFromJson` for one `lang/<code>.json`: its entries go into `map` in file order.
|
||||
/// A file that is plain JSON reads as before; any other goes through Gson's lenient reader
|
||||
/// (`#`, `//` and `/* */` comments, unquoted or single-quoted names and values, raw control
|
||||
/// characters in strings, `=` or `=>` and `;`), which vanilla reads lang files with (expected
|
||||
/// from `Language.loadFromJson`, Gson). A file that is not JSON even so adds nothing; a value
|
||||
/// that is not a string, number or boolean (`GsonHelper.convertToString`) ends the file there.
|
||||
fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
|
||||
use horizon_world::datafix::json::{Json, parse_lenient};
|
||||
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(bytes) {
|
||||
map.extend(entries);
|
||||
return;
|
||||
}
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
// Gson's JsonReader skips a leading byte order mark.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
|
||||
let utf16: Vec<u16> = text.encode_utf16().collect();
|
||||
let Some(Json::Obj(entries)) = parse_lenient(&utf16) else {
|
||||
return;
|
||||
};
|
||||
for (key, value) in entries {
|
||||
let value = match value {
|
||||
Json::Str(s) => String::from_utf16_lossy(&s),
|
||||
Json::Num(n) => n,
|
||||
Json::Bool(b) => b.to_string(),
|
||||
Json::Null | Json::Arr(_) | Json::Obj(_) => break,
|
||||
};
|
||||
map.insert(String::from_utf16_lossy(&key), value);
|
||||
}
|
||||
}
|
||||
|
||||
impl Language {
|
||||
/// ClientLanguage.loadFrom: en_us first, then the selected language, each
|
||||
/// from every namespace and every source in priority order (later sources
|
||||
@@ -413,9 +443,7 @@ impl Language {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(&bytes) {
|
||||
map.extend(entries);
|
||||
}
|
||||
read_lang_file(&bytes, &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -875,4 +903,165 @@ mod tests {
|
||||
["en_us", "fr_fr"]
|
||||
);
|
||||
}
|
||||
|
||||
fn read(body: &str) -> HashMap<String, String> {
|
||||
let mut map = HashMap::new();
|
||||
read_lang_file(body.as_bytes(), &mut map);
|
||||
map
|
||||
}
|
||||
fn entries(pairs: &[(&str, &str)]) -> HashMap<String, String> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_json_lang_file_reads_as_before() {
|
||||
let body = "{\"a\": \"Apple \\\"quoted\\\" \\u00e9\", \"b\": \"\", \"a\": \"Again\"}";
|
||||
let strict: HashMap<String, String> = serde_json::from_str(body).unwrap();
|
||||
assert_eq!(read(body), strict);
|
||||
assert_eq!(read(body), entries(&[("a", "Again"), ("b", "")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_files_read_with_comments() {
|
||||
let want = entries(&[("a", "Apple"), ("b", "Banana # not a comment // either")]);
|
||||
for body in [
|
||||
"# header\n{\n# a comment\n\"a\": \"Apple\",\n\"b\": \"Banana # not a comment // either\"\n}\n",
|
||||
"// header\n{\"a\": \"Apple\", // after\n\"b\": \"Banana # not a comment // either\"}",
|
||||
"/* header\n over lines */{\"a\": /* inline */ \"Apple\", \"b\": \"Banana # not a comment // either\"}",
|
||||
] {
|
||||
assert_eq!(read(body), want, "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_files_read_unquoted_and_single_quoted_names_and_values() {
|
||||
assert_eq!(
|
||||
read("{a: 'Apple', 'b': \"Banana\", c: Cherry}"),
|
||||
entries(&[("a", "Apple"), ("b", "Banana"), ("c", "Cherry")])
|
||||
);
|
||||
// A single-quoted string may hold a double quote, and the reverse.
|
||||
assert_eq!(
|
||||
read("{'a': 'say \"hi\"', \"b\": \"it's\"}"),
|
||||
entries(&[("a", "say \"hi\""), ("b", "it's")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_files_read_raw_control_characters_in_strings() {
|
||||
assert_eq!(
|
||||
read("{\"a\": \"tab\there\", \"b\": \"two\nlines\"}"),
|
||||
entries(&[("a", "tab\there"), ("b", "two\nlines")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lang_files_read_gson_separators_and_a_byte_order_mark() {
|
||||
assert_eq!(
|
||||
read("\u{feff}{\"a\" = \"Apple\"; \"b\" => \"Banana\"}"),
|
||||
entries(&[("a", "Apple"), ("b", "Banana")])
|
||||
);
|
||||
// A trailing comma before `}` is not JSON even to Gson's lenient reader.
|
||||
assert_eq!(read("{# c\n\"a\": \"Apple\",}"), HashMap::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_and_booleans_read_as_their_text() {
|
||||
// GsonHelper.convertToString: a primitive reads as its text.
|
||||
assert_eq!(
|
||||
read("{\"a\": 1.50, \"b\": true, \"c\": -2e3}"),
|
||||
entries(&[("a", "1.50"), ("b", "true"), ("c", "-2e3")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_that_is_no_text_ends_the_file_there() {
|
||||
assert_eq!(
|
||||
read("# c\n{\"a\": \"Apple\", \"b\": [\"x\"], \"c\": \"Cherry\"}"),
|
||||
entries(&[("a", "Apple")])
|
||||
);
|
||||
assert_eq!(read("{\"a\": null, \"b\": \"Banana\"}"), HashMap::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lang_file_that_is_not_json_adds_nothing() {
|
||||
for body in [
|
||||
"# c\n{\"a\": \"Apple\"",
|
||||
"[\"a\"]",
|
||||
"\"a\"",
|
||||
"",
|
||||
"{\"a\": \"Apple\"} {",
|
||||
] {
|
||||
assert_eq!(read(body), HashMap::new(), "{body:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_lang_file_with_comments_layers_over_english() {
|
||||
let base = Mem(HashMap::from([(
|
||||
"assets/minecraft/lang/en_us.json".into(),
|
||||
br#"{"a":"Apple","b":"Banana"}"#.to_vec(),
|
||||
)]));
|
||||
let pack = Mem(HashMap::from([(
|
||||
"assets/minecraft/lang/fr_fr.json".into(),
|
||||
b"{\n# Fruits\n\"a\": \"Pomme\"\n}\n".to_vec(),
|
||||
)]));
|
||||
let sources: [&dyn ResourceSource; 2] = [&base, &pack];
|
||||
let fr = Language::load(&sources, "fr_fr");
|
||||
assert_eq!(fr.get("a"), Some("Pomme"));
|
||||
assert_eq!(fr.get("b"), Some("Banana"));
|
||||
}
|
||||
|
||||
/// Every lang file of a downloaded pack, e.g. BACAP Language Pack:
|
||||
/// `HORIZON_LANG_PACK_DIR=<pack>/assets/minecraft/lang cargo test -p horizon-client-core
|
||||
/// lang::tests::every_lang_file_of_a_pack_reads -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "needs a downloaded pack in HORIZON_LANG_PACK_DIR"]
|
||||
fn every_lang_file_of_a_pack_reads() {
|
||||
let Ok(dir) = std::env::var("HORIZON_LANG_PACK_DIR") else {
|
||||
return;
|
||||
};
|
||||
let mut failed = Vec::new();
|
||||
let mut paths: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.collect();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let strict = serde_json::from_slice::<HashMap<String, String>>(&bytes).is_ok();
|
||||
let mut map = HashMap::new();
|
||||
read_lang_file(&bytes, &mut map);
|
||||
// An independent count: the distinct `"name":` that start a line.
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let names: std::collections::HashSet<&str> = text
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let rest = line.trim_start().strip_prefix('"')?;
|
||||
let mut escaped = false;
|
||||
let end = rest.char_indices().find_map(|(i, c)| {
|
||||
let close = c == '"' && !escaped;
|
||||
escaped = c == '\\' && !escaped;
|
||||
close.then_some(i)
|
||||
})?;
|
||||
rest[end + 1..]
|
||||
.trim_start()
|
||||
.starts_with(':')
|
||||
.then(|| &rest[..end])
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}: strict {strict}, {} keys, {} names",
|
||||
path.display(),
|
||||
map.len(),
|
||||
names.len()
|
||||
);
|
||||
if map.is_empty() || map.len() != names.len() {
|
||||
failed.push(path);
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{failed:?}");
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,20 @@ impl NbtPath {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
/// Whether this selects a compound-field chain, including filtered or quoted fields.
|
||||
/// Matching a root or child does not change that subtree's writer provenance.
|
||||
pub fn is_field_path(&self, fields: &[&str]) -> bool {
|
||||
let mut fields = fields.iter();
|
||||
for node in &self.nodes {
|
||||
match node {
|
||||
Node::MatchRoot(_) => {}
|
||||
Node::Child(name) | Node::MatchChild(name, _)
|
||||
if fields.next().copied() == Some(name.as_str()) => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
fields.next().is_none()
|
||||
}
|
||||
fn not_found(&self, node: usize) -> PathError {
|
||||
let end = self.ends[node];
|
||||
PathError::new(
|
||||
@@ -826,6 +840,25 @@ fn parse_compound(text: &str, i: usize) -> Result<(Tag, usize), PathError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn field_provenance_handles_quotes_and_filters_without_matching_lists() {
|
||||
for text in [
|
||||
"blockState.properties",
|
||||
"\"blockState\".\"properties\"",
|
||||
"{source:1b}.blockState.properties",
|
||||
"blockState{id:\"minecraft:piston_head\"}.properties",
|
||||
] {
|
||||
let path = NbtPath::parse(text).unwrap();
|
||||
assert!(path.is_field_path(&["blockState", "properties"]));
|
||||
assert!(!path.is_field_path(&["blockState"]));
|
||||
}
|
||||
assert!(NbtPath::parse("{source:1b}").unwrap().is_field_path(&[]));
|
||||
assert!(
|
||||
!NbtPath::parse("blockState[].properties")
|
||||
.unwrap()
|
||||
.is_field_path(&["blockState", "properties"])
|
||||
);
|
||||
}
|
||||
|
||||
fn tag(s: &str) -> Tag {
|
||||
parse_snbt(s).unwrap().0
|
||||
|
||||
@@ -8,11 +8,20 @@ pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to
|
||||
|
||||
// ---- the coloured form -----------------------------------------------------
|
||||
|
||||
/// Writer provenance for a live moving-piston command read, or its selected subtree.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum PistonPart {
|
||||
Entity,
|
||||
State,
|
||||
Properties,
|
||||
}
|
||||
|
||||
struct Pretty {
|
||||
indentation: String,
|
||||
result: Text,
|
||||
indent_depth: usize,
|
||||
depth: usize,
|
||||
piston: Option<PistonPart>,
|
||||
}
|
||||
fn is_numeric(t: &Tag) -> bool {
|
||||
matches!(
|
||||
@@ -58,6 +67,10 @@ impl Pretty {
|
||||
self.lit("]", None);
|
||||
}
|
||||
fn sub(&mut self, tag: &Tag, indent: bool) {
|
||||
self.sub_with(tag, indent, None);
|
||||
}
|
||||
fn sub_with(&mut self, tag: &Tag, indent: bool, piston: Option<PistonPart>) {
|
||||
let previous = std::mem::replace(&mut self.piston, piston);
|
||||
if indent {
|
||||
self.indent_depth += 1;
|
||||
}
|
||||
@@ -67,6 +80,7 @@ impl Pretty {
|
||||
self.indent_depth -= 1;
|
||||
}
|
||||
self.depth -= 1;
|
||||
self.piston = previous;
|
||||
}
|
||||
fn visit(&mut self, tag: &Tag) {
|
||||
match tag {
|
||||
@@ -157,7 +171,11 @@ impl Pretty {
|
||||
"\n"
|
||||
};
|
||||
let n = c.len();
|
||||
for (i, (k, v)) in java_hash_order(c).into_iter().enumerate() {
|
||||
let entries = match self.piston {
|
||||
Some(part) => piston_hash_order(c, part),
|
||||
None => java_hash_order(c),
|
||||
};
|
||||
for (i, (k, v)) in entries.into_iter().enumerate() {
|
||||
self.lit(&entry, None);
|
||||
let key = string_of(k);
|
||||
if !key.is_empty()
|
||||
@@ -171,7 +189,12 @@ impl Pretty {
|
||||
}
|
||||
self.lit(":", None);
|
||||
self.lit(" ", None);
|
||||
self.sub(v, true);
|
||||
let child = match (self.piston, key.as_str()) {
|
||||
(Some(PistonPart::Entity), "blockState") => Some(PistonPart::State),
|
||||
(Some(PistonPart::State), "properties") => Some(PistonPart::Properties),
|
||||
_ => None,
|
||||
};
|
||||
self.sub_with(v, true, child);
|
||||
if i + 1 < n {
|
||||
self.lit(",", None);
|
||||
self.lit(spacing, None);
|
||||
@@ -186,22 +209,61 @@ impl Pretty {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn hash_bucket(len: usize, key: &horizon_world::nbt::JavaString) -> usize {
|
||||
let mut capacity = 16usize;
|
||||
while len > capacity * 3 / 4 {
|
||||
capacity *= 2;
|
||||
}
|
||||
let h = string_of(key)
|
||||
.encode_utf16()
|
||||
.fold(0u32, |h, ch| h.wrapping_mul(31).wrapping_add(u32::from(ch)));
|
||||
((h ^ (h >> 16)) as usize) & (capacity - 1)
|
||||
}
|
||||
fn piston_hash_order(
|
||||
c: &horizon_world::nbt::Compound,
|
||||
part: PistonPart,
|
||||
) -> Vec<(&horizon_world::nbt::JavaString, &Tag)> {
|
||||
let mut entries: Vec<_> = c.iter().collect();
|
||||
match part {
|
||||
PistonPart::Entity => {
|
||||
// saveAdditional, then the base's components and saveMetadata.
|
||||
let fields = [
|
||||
"blockState",
|
||||
"facing",
|
||||
"progress",
|
||||
"extending",
|
||||
"source",
|
||||
"components",
|
||||
"id",
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
];
|
||||
entries.sort_by_key(|(k, _)| {
|
||||
(
|
||||
hash_bucket(c.len(), k),
|
||||
fields
|
||||
.iter()
|
||||
.position(|s| *s == string_of(k))
|
||||
.unwrap_or(fields.len()),
|
||||
)
|
||||
});
|
||||
}
|
||||
PistonPart::Properties => {
|
||||
// StateDefinition builds property codecs in sorted-name order.
|
||||
// DFU's nested map-pair encoder writes the second codec first.
|
||||
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
|
||||
}
|
||||
PistonPart::State => return java_hash_order(c),
|
||||
}
|
||||
entries
|
||||
}
|
||||
/// The iteration order of the `HashMap<String, Tag>` a `CompoundTag` holds,
|
||||
/// for a map that grew to its size by insertion: buckets by
|
||||
/// `HashMap.hash(String.hashCode())`, keys sharing a bucket in key order.
|
||||
pub fn java_hash_order(
|
||||
c: &horizon_world::nbt::Compound,
|
||||
) -> Vec<(&horizon_world::nbt::JavaString, &Tag)> {
|
||||
let mut capacity = 16usize;
|
||||
while c.len() > capacity * 3 / 4 {
|
||||
capacity *= 2;
|
||||
}
|
||||
let bucket = |k: &horizon_world::nbt::JavaString| {
|
||||
let h = string_of(k)
|
||||
.encode_utf16()
|
||||
.fold(0u32, |h, ch| h.wrapping_mul(31).wrapping_add(u32::from(ch)));
|
||||
((h ^ (h >> 16)) as usize) & (capacity - 1)
|
||||
};
|
||||
// Keys sharing a bucket iterate in insertion order: the order vanilla's
|
||||
// savers write them, as far as it is known here.
|
||||
const WRITE_ORDER: &[&str] = &[
|
||||
@@ -268,7 +330,7 @@ pub fn java_hash_order(
|
||||
.unwrap_or(WRITE_ORDER.len())
|
||||
};
|
||||
let mut entries: Vec<_> = c.iter().collect();
|
||||
entries.sort_by_key(|(k, _)| (bucket(k), rank(k)));
|
||||
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), rank(k)));
|
||||
entries
|
||||
}
|
||||
/// `NbtUtils.toPrettyComponent`: the coloured, unindented form.
|
||||
@@ -281,6 +343,19 @@ pub fn pretty_indented(tag: &Tag, indentation: &str) -> Text {
|
||||
result: Text::empty(),
|
||||
indent_depth: 0,
|
||||
depth: 0,
|
||||
piston: None,
|
||||
};
|
||||
p.visit(tag);
|
||||
p.result
|
||||
}
|
||||
/// Command-only writer order; ordinary pretty printing keeps its existing policy.
|
||||
pub fn pretty_piston(tag: &Tag, part: PistonPart) -> Text {
|
||||
let mut p = Pretty {
|
||||
indentation: String::new(),
|
||||
result: Text::empty(),
|
||||
indent_depth: 0,
|
||||
depth: 0,
|
||||
piston: Some(part),
|
||||
};
|
||||
p.visit(tag);
|
||||
p.result
|
||||
@@ -295,6 +370,33 @@ mod tests {
|
||||
to_snbt(&parse_snbt(s).unwrap().0)
|
||||
}
|
||||
#[test]
|
||||
fn live_piston_writer_order_does_not_change_default_pretty() {
|
||||
let tag = parse_snbt(
|
||||
r#"{id:"minecraft:piston",blockState:{id:"minecraft:piston_head",properties:{facing:"east",short:"false",type:"normal"}},components:{},x:1,y:100,z:0,facing:5b,progress:0.0f,source:1b,extending:1b}"#,
|
||||
).unwrap().0;
|
||||
let ordinary = r#"{blockState: {id: "minecraft:piston_head", properties: {facing: "east", short: "false", type: "normal"}}, components: {}, x: 1, facing: 5b, progress: 0.0f, y: 100, z: 0, id: "minecraft:piston", source: 1b, extending: 1b}"#;
|
||||
assert_eq!(pretty(&tag).plain(), ordinary);
|
||||
assert_eq!(
|
||||
pretty_piston(&tag, PistonPart::Entity).plain(),
|
||||
r#"{blockState: {id: "minecraft:piston_head", properties: {short: "false", facing: "east", type: "normal"}}, components: {}, x: 1, facing: 5b, progress: 0.0f, y: 100, z: 0, source: 1b, id: "minecraft:piston", extending: 1b}"#,
|
||||
);
|
||||
let state = tag.get("blockState").unwrap();
|
||||
assert_eq!(
|
||||
pretty_piston(state, PistonPart::State).plain(),
|
||||
r#"{id: "minecraft:piston_head", properties: {short: "false", facing: "east", type: "normal"}}"#,
|
||||
);
|
||||
let props = state.get("properties").unwrap();
|
||||
assert_eq!(
|
||||
pretty_piston(props, PistonPart::Properties).plain(),
|
||||
r#"{short: "false", facing: "east", type: "normal"}"#,
|
||||
);
|
||||
assert_eq!(
|
||||
pretty(props).plain(),
|
||||
r#"{facing: "east", short: "false", type: "normal"}"#
|
||||
);
|
||||
assert_eq!(pretty(&tag).plain(), ordinary);
|
||||
}
|
||||
#[test]
|
||||
fn compact_form_matches_vanilla() {
|
||||
assert_eq!(
|
||||
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
|
||||
|
||||
@@ -119,6 +119,19 @@ impl Button {
|
||||
][face],
|
||||
}
|
||||
}
|
||||
/// The face button that plays this one's part on a Nintendo pad going by its letters
|
||||
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
|
||||
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
|
||||
/// its own inverse, so it turns a part into the button playing it and back.
|
||||
pub fn by_letter(self) -> Button {
|
||||
match self {
|
||||
Button::South => Button::East,
|
||||
Button::East => Button::South,
|
||||
Button::West => Button::North,
|
||||
Button::North => Button::West,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pad's inputs at one moment. Sticks are `[x, y]` with right and up
|
||||
@@ -142,6 +155,14 @@ impl PadState {
|
||||
pub fn held(&self, button: Button) -> bool {
|
||||
self.buttons & button.bit() != 0
|
||||
}
|
||||
/// A Nintendo pad's buttons read by their letters (`Button::by_letter`).
|
||||
pub fn by_letter(&self) -> PadState {
|
||||
let mut state = *self;
|
||||
for part in [Button::South, Button::East, Button::West, Button::North] {
|
||||
state.set(part, self.held(part.by_letter()));
|
||||
}
|
||||
state
|
||||
}
|
||||
/// Two sources at once (a real pad and `horizon-ctl pad`): buttons held on
|
||||
/// either, sticks and triggers added.
|
||||
pub fn merge(&self, other: &PadState) -> PadState {
|
||||
@@ -454,6 +475,10 @@ pub struct PadSettings {
|
||||
/// holding it, unless vanilla's toggle crouch is on already.
|
||||
pub toggle_sneak: bool,
|
||||
pub glyphs: GlyphStyle,
|
||||
/// A Nintendo pad's face buttons go by their letters (A confirms and jumps, as on the
|
||||
/// Switch's own menus and keyboard) instead of their places (B, at the bottom, does): on
|
||||
/// by default on the Switch only.
|
||||
pub nintendo_letters: bool,
|
||||
pub keyboard: TextKeyboard,
|
||||
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
|
||||
pub pointer_speed: f32,
|
||||
@@ -478,6 +503,7 @@ impl Default for PadSettings {
|
||||
trigger_threshold: 0.4,
|
||||
toggle_sneak: true,
|
||||
glyphs: GlyphStyle::Auto,
|
||||
nintendo_letters: cfg!(target_os = "horizon"),
|
||||
keyboard: TextKeyboard::Auto,
|
||||
pointer_speed: 1.0,
|
||||
scroll_speed: 12.0,
|
||||
@@ -545,6 +571,7 @@ impl PadSettings {
|
||||
}
|
||||
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
|
||||
"glyphs" => s.glyphs = GlyphStyle::parse(value),
|
||||
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
|
||||
"keyboard" => s.keyboard = TextKeyboard::parse(value),
|
||||
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
|
||||
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
|
||||
@@ -568,7 +595,7 @@ impl PadSettings {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nkeyboard:{}\n",
|
||||
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nnintendoLetters:{}\nkeyboard:{}\n",
|
||||
bool_text(self.enabled),
|
||||
bool_text(self.vibration),
|
||||
bool_text(self.adaptive_triggers),
|
||||
@@ -581,6 +608,7 @@ impl PadSettings {
|
||||
self.trigger_threshold,
|
||||
bool_text(self.toggle_sneak),
|
||||
self.glyphs.name(),
|
||||
bool_text(self.nintendo_letters),
|
||||
self.keyboard.name(),
|
||||
);
|
||||
text.push_str(&format!(
|
||||
@@ -1391,7 +1419,9 @@ mod tests {
|
||||
s.invert_y = true;
|
||||
s.look_sensitivity = 0.75;
|
||||
s.glyphs = GlyphStyle::PlayStation;
|
||||
s.nintendo_letters = !s.nintendo_letters;
|
||||
let text = s.serialize();
|
||||
assert!(text.contains(&format!("nintendoLetters:{}\n", s.nintendo_letters)));
|
||||
assert!(text.contains("bind_key.jump:east\n"));
|
||||
assert!(text.contains("bind_key.drop:none\n"));
|
||||
assert!(!text.contains("key.sneak"));
|
||||
@@ -1437,6 +1467,27 @@ mod tests {
|
||||
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nintendo_pad_by_its_letters_confirms_with_a() {
|
||||
// The pad's A sits on the right (East): going by letters it plays South, confirm and jump.
|
||||
let mut pad = PadState::default();
|
||||
pad.set(Button::East, true);
|
||||
pad.set(Button::LeftBumper, true);
|
||||
pad.left = [0.5, -0.25];
|
||||
let parts = pad.by_letter();
|
||||
assert!(parts.held(Button::South));
|
||||
assert!(!parts.held(Button::East));
|
||||
// Other buttons and the sticks stay; reading by letters again gives the pad back.
|
||||
assert!(parts.held(Button::LeftBumper));
|
||||
assert_eq!(parts.left, [0.5, -0.25]);
|
||||
assert_eq!(parts.by_letter(), pad);
|
||||
// A part's glyph is the letter of the button playing it.
|
||||
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
|
||||
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
|
||||
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
|
||||
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radial_deadzone_rescales() {
|
||||
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
|
||||
|
||||
@@ -355,6 +355,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
@@ -377,6 +378,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
|
||||
@@ -352,6 +352,7 @@ impl RenderAssets {
|
||||
}
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_oit_stage_{stage}"))?;
|
||||
}
|
||||
let fusion = if draws {
|
||||
let _step = crate::spans::startup::step("core.fusion");
|
||||
|
||||
@@ -208,6 +208,18 @@ fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: b
|
||||
type Stream = cpal::Stream;
|
||||
#[cfg(not(feature = "audio"))]
|
||||
type Stream = std::convert::Infallible;
|
||||
/// A level sound retained for the network broadcaster, in playback order.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CapturedSound {
|
||||
pub event: String,
|
||||
pub position: [f64; 3],
|
||||
pub volume: f32,
|
||||
pub pitch: f32,
|
||||
pub source: usize,
|
||||
pub entity: Option<u32>,
|
||||
pub except_entity: Option<u32>,
|
||||
}
|
||||
|
||||
pub struct Audio {
|
||||
_stream: Option<Stream>,
|
||||
device_name: String,
|
||||
@@ -224,7 +236,7 @@ pub struct Audio {
|
||||
source_override: Option<usize>,
|
||||
/// Positional sounds played since the last drain, kept while the world
|
||||
/// is open to the network so guests hear them too.
|
||||
pub capture: Option<Vec<(String, [f64; 3], f32, f32)>>,
|
||||
pub capture: Option<Vec<CapturedSound>>,
|
||||
/// How many times each positional sound played (the control socket's `status`),
|
||||
/// with or without an audio device.
|
||||
pub heard: std::collections::BTreeMap<String, u32>,
|
||||
@@ -1297,7 +1309,17 @@ impl Audio {
|
||||
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
|
||||
let seed = self.random.next_long();
|
||||
if let Some(capture) = self.capture.as_mut() {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
capture.push(CapturedSound {
|
||||
event: event.to_owned(),
|
||||
position: at,
|
||||
volume,
|
||||
pitch,
|
||||
source: self.source_override.unwrap_or_else(|| {
|
||||
crate::settings::sound_source(&crate::sound_events::normalize(event))
|
||||
}),
|
||||
entity: None,
|
||||
except_entity: None,
|
||||
});
|
||||
}
|
||||
*self.heard.entry(event.to_owned()).or_default() += 1;
|
||||
self.sync_volumes();
|
||||
@@ -1336,6 +1358,66 @@ impl Audio {
|
||||
preview.preview(self, source, volume);
|
||||
self.preview = preview;
|
||||
}
|
||||
/// An incoming entity-sound packet: retain its seed and category without rebroadcasting it.
|
||||
pub fn play_entity_seeded(
|
||||
&mut self,
|
||||
event: &str,
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
seed: i64,
|
||||
entity: crate::sound_engine::SoundEntity,
|
||||
) {
|
||||
*self.heard.entry(event.to_owned()).or_default() += 1;
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let instance = SoundInstance::entity_bound(
|
||||
&event,
|
||||
source,
|
||||
volume,
|
||||
pitch,
|
||||
LegacyRandom::new(seed),
|
||||
entity,
|
||||
);
|
||||
self.play_instance(instance, false);
|
||||
}
|
||||
/// A level's entity overload: preserve the emitter and excluded player for
|
||||
/// the server broadcaster while the local sound follows the same object.
|
||||
pub fn play_bound(
|
||||
&mut self,
|
||||
event: &str,
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
entity: crate::sound_engine::SoundEntity,
|
||||
except_entity: Option<u32>,
|
||||
) {
|
||||
if let Some(capture) = self.capture.as_mut() {
|
||||
capture.push(CapturedSound {
|
||||
event: event.to_owned(),
|
||||
position: entity.state().position,
|
||||
volume,
|
||||
pitch,
|
||||
source,
|
||||
entity: Some(entity.id),
|
||||
except_entity,
|
||||
});
|
||||
}
|
||||
let seed = self.random.next_long();
|
||||
self.play_entity_seeded(event, source, volume, pitch, seed, entity);
|
||||
}
|
||||
/// Removal belongs to the old object even if its numeric id is immediately reused.
|
||||
pub fn remove_sound_entity(&mut self, id: u32) {
|
||||
for entity in self
|
||||
.engine
|
||||
.bound_entities()
|
||||
.into_iter()
|
||||
.filter(|e| e.id == id)
|
||||
{
|
||||
let mut state = entity.state();
|
||||
state.removed = true;
|
||||
entity.update(state);
|
||||
}
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
@@ -1346,7 +1428,17 @@ impl Audio {
|
||||
seed: i64,
|
||||
) {
|
||||
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
|
||||
capture.push((event.to_owned(), at, volume, pitch));
|
||||
capture.push(CapturedSound {
|
||||
event: event.to_owned(),
|
||||
position: at,
|
||||
volume,
|
||||
pitch,
|
||||
source: self.source_override.unwrap_or_else(|| {
|
||||
crate::settings::sound_source(&crate::sound_events::normalize(event))
|
||||
}),
|
||||
entity: None,
|
||||
except_entity: None,
|
||||
});
|
||||
}
|
||||
if at.is_some() {
|
||||
*self.heard.entry(event.to_owned()).or_default() += 1;
|
||||
@@ -1486,6 +1578,81 @@ impl Sounds for Audio {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn level_capture_keeps_order_category_entity_and_actor_exclusion() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
|
||||
audio.capture = Some(Vec::new());
|
||||
let mut expected = audio.random.clone();
|
||||
audio.play_on(
|
||||
7,
|
||||
"minecraft:block.amethyst_block.chime",
|
||||
Some([1.0; 3]),
|
||||
0.5,
|
||||
1.0,
|
||||
);
|
||||
let entity = crate::sound_engine::SoundEntity::new(37, [2.123456789, 64.0, -3.0], false);
|
||||
audio.play_bound(
|
||||
"minecraft:block.amethyst_block.chime",
|
||||
6,
|
||||
2.0,
|
||||
1.2,
|
||||
entity,
|
||||
Some(17),
|
||||
);
|
||||
let sounds = audio.capture.as_ref().unwrap();
|
||||
assert_eq!(sounds.len(), 2);
|
||||
assert_eq!(
|
||||
(sounds[0].source, sounds[0].entity, sounds[0].except_entity),
|
||||
(7, None, None)
|
||||
);
|
||||
let bound = &sounds[1];
|
||||
assert_eq!(
|
||||
(bound.source, bound.entity, bound.except_entity),
|
||||
(6, Some(37), Some(17))
|
||||
);
|
||||
assert_eq!(bound.position, [2.123456789, 64.0, -3.0]);
|
||||
assert_eq!((bound.volume, bound.pitch), (2.0, 1.2));
|
||||
expected.next_long();
|
||||
expected.next_long();
|
||||
assert_eq!(audio.random.next_long(), expected.next_long());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_sound_packets_neither_rebroadcast_nor_advance_the_local_seed_generator() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
|
||||
audio.capture = Some(Vec::new());
|
||||
let mut expected = audio.random.clone();
|
||||
let entity = crate::sound_engine::SoundEntity::new(7, [1.0, 2.0, 3.0], false);
|
||||
audio.play_entity_seeded("minecraft:fixture.bound", 6, 0.7, 1.2, 42, entity);
|
||||
assert!(audio.capture.as_ref().unwrap().is_empty());
|
||||
assert_eq!(audio.random.next_long(), expected.next_long());
|
||||
}
|
||||
#[test]
|
||||
fn removal_marks_only_existing_references_to_that_entity() {
|
||||
use crate::sound_engine::SoundEntity;
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
|
||||
let old = SoundEntity::new(7, [0.0; 3], false);
|
||||
let other = SoundEntity::new(8, [0.0; 3], false);
|
||||
for entity in [old.clone(), other.clone()] {
|
||||
audio.engine.add(
|
||||
SoundInstance::entity_bound(
|
||||
"minecraft:fixture.bound",
|
||||
6,
|
||||
1.0,
|
||||
1.0,
|
||||
LegacyRandom::new(42),
|
||||
entity,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
audio.remove_sound_entity(7);
|
||||
let replacement = SoundEntity::new(7, [10.0; 3], false);
|
||||
assert!(old.state().removed);
|
||||
assert!(!other.state().removed);
|
||||
assert!(!replacement.state().removed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_click_uses_vanilla_ui_parameters_without_advancing_world_random() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
|
||||
|
||||
@@ -49,6 +49,10 @@ impl AuthSession {
|
||||
access_token: access_token.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn access_token(&self) -> &str {
|
||||
&self.access_token
|
||||
}
|
||||
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let v: serde_json::Value =
|
||||
|
||||
@@ -696,11 +696,19 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
/// reader instead (a twentieth of a font pixel, invisible) rather than
|
||||
/// z-fighting with the outline under it.
|
||||
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
text_vertices(scene.glyph, call, out);
|
||||
}
|
||||
|
||||
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
|
||||
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
|
||||
/// y down as the font's, with its outline when it has one. A native mod's
|
||||
/// block entity draws its text this way (`native_draw_text`).
|
||||
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
let g = (scene.glyph)(ch);
|
||||
let g = glyph(ch);
|
||||
if let Some(cell) = g.rect {
|
||||
// BakedSheetGlyph: the glyph's own box from the pen, not a
|
||||
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
|
||||
|
||||
@@ -174,6 +174,12 @@ impl ChatOptions {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Message {
|
||||
added: u64,
|
||||
/// Local hour and minute it came in (PvP & HUD's chat timestamps).
|
||||
time: (u8, u8),
|
||||
/// How many times in a row it came (PvP & HUD's compact chat), 1 for once.
|
||||
repeats: u32,
|
||||
/// The lines it wrapped to the last time it was laid out (0 while hidden).
|
||||
wrapped: usize,
|
||||
content: Text,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
source: ChatSource,
|
||||
@@ -186,6 +192,32 @@ struct Line {
|
||||
added: u64,
|
||||
content: Text,
|
||||
tag: Option<std::sync::Arc<Tag>>,
|
||||
/// The whole message's text, for PvP & HUD's copy on click.
|
||||
message: std::sync::Arc<str>,
|
||||
}
|
||||
|
||||
/// PvP & HUD's chat options (`pvp::ChatQol`): each changes the lines, so a change rewraps them.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ChatQol {
|
||||
/// Each message starts with the local time it came in.
|
||||
pub timestamps: bool,
|
||||
/// A message equal to the one before it raises that one's count ("(x3)") instead of adding lines.
|
||||
pub compact: bool,
|
||||
}
|
||||
/// Whether `next` repeats `last`: the same text, kind and tag, neither one signed (a server may
|
||||
/// delete a signed message by itself).
|
||||
fn repeats(last: &Message, next: &Message) -> bool {
|
||||
last.signature.is_none()
|
||||
&& next.signature.is_none()
|
||||
&& last.source == next.source
|
||||
&& last.tag == next.tag
|
||||
&& last.content == next.content
|
||||
}
|
||||
/// The local hour and minute.
|
||||
fn local_time() -> (u8, u8) {
|
||||
use chrono::Timelike;
|
||||
let now = chrono::Local::now();
|
||||
(now.hour() as u8, now.minute() as u8)
|
||||
}
|
||||
|
||||
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
|
||||
@@ -270,6 +302,14 @@ pub struct Chat {
|
||||
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
|
||||
/// too new to replace, with the tick from which they may be.
|
||||
deletions: Vec<(Box<Signature>, u64)>,
|
||||
qol: ChatQol,
|
||||
/// PvP & HUD's chat search is on: the screen shows its prompt.
|
||||
pub search_prompt: bool,
|
||||
/// PvP & HUD's chat search: only messages holding this (lower case) show.
|
||||
search: Option<String>,
|
||||
/// The tests' clock in place of the local time.
|
||||
#[cfg(test)]
|
||||
clock: Option<(u8, u8)>,
|
||||
}
|
||||
impl Chat {
|
||||
pub fn lines_per_page(&self, focused: bool) -> usize {
|
||||
@@ -331,6 +371,9 @@ impl Chat {
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
time: (0, 0),
|
||||
repeats: 1,
|
||||
wrapped: 0,
|
||||
content,
|
||||
tag: tag.map(std::sync::Arc::new),
|
||||
source: ChatSource::Player,
|
||||
@@ -352,6 +395,9 @@ impl Chat {
|
||||
font,
|
||||
Message {
|
||||
added: ticks,
|
||||
time: (0, 0),
|
||||
repeats: 1,
|
||||
wrapped: 0,
|
||||
content,
|
||||
tag,
|
||||
source,
|
||||
@@ -360,7 +406,7 @@ impl Chat {
|
||||
focused,
|
||||
);
|
||||
}
|
||||
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
|
||||
fn push_message(&mut self, font: &crate::font::Font, mut message: Message, focused: bool) {
|
||||
if !self.abilities.shows(message.source) {
|
||||
return;
|
||||
}
|
||||
@@ -370,12 +416,52 @@ impl Chat {
|
||||
if self.log {
|
||||
println!("[CHAT] {plain}");
|
||||
}
|
||||
self.add_lines(font, &message, focused);
|
||||
message.time = self.now();
|
||||
self.received += 1;
|
||||
if self.qol.compact
|
||||
&& let Some(last) = self.messages.first()
|
||||
&& repeats(last, &message)
|
||||
{
|
||||
// The newest lines are that message's: they make way for its lines with the count.
|
||||
let mut last = self.messages.remove(0);
|
||||
let shown = last.wrapped.min(self.lines.len());
|
||||
self.lines.drain(..shown);
|
||||
// A reader scrolled up keeps their view: only lines the merge adds move it.
|
||||
let (scroll, new_since_scroll) = (self.scroll, self.new_since_scroll);
|
||||
last.repeats += 1;
|
||||
last.added = message.added;
|
||||
last.time = message.time;
|
||||
last.wrapped = self.add_lines(font, &last, focused);
|
||||
if scroll > 0 {
|
||||
self.scroll = scroll;
|
||||
self.new_since_scroll = new_since_scroll;
|
||||
self.scroll_by(last.wrapped as i32 - shown as i32, focused);
|
||||
}
|
||||
self.messages.insert(0, last);
|
||||
return;
|
||||
}
|
||||
message.wrapped = self.add_lines(font, &message, focused);
|
||||
self.messages.insert(0, message);
|
||||
self.messages.truncate(100);
|
||||
self.received += 1;
|
||||
}
|
||||
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
|
||||
fn now(&self) -> (u8, u8) {
|
||||
#[cfg(test)]
|
||||
if let Some(time) = self.clock {
|
||||
return time;
|
||||
}
|
||||
local_time()
|
||||
}
|
||||
/// The lines `message` wraps to (none when the chat search hides it), added newest first.
|
||||
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) -> usize {
|
||||
// What the chat shows: legacy `§` codes format the text, they aren't part of it.
|
||||
let whole = message.content.strip_legacy().plain();
|
||||
if let Some(query) = &self.search
|
||||
&& !whole.to_lowercase().contains(query.as_str())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
let whole: std::sync::Arc<str> = whole.into();
|
||||
let mut count = 0;
|
||||
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
|
||||
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
|
||||
9 + 4 + 2
|
||||
@@ -383,8 +469,9 @@ impl Chat {
|
||||
0
|
||||
};
|
||||
let width = self.max_width() - icon;
|
||||
let content = self.displayed(&message.content);
|
||||
let content = self.message_text(message);
|
||||
for line in font.wrap_components(&content, width) {
|
||||
count += 1;
|
||||
if focused && self.scroll > 0 {
|
||||
self.new_since_scroll = true;
|
||||
self.scroll_by(1, focused);
|
||||
@@ -395,10 +482,12 @@ impl Chat {
|
||||
added: message.added,
|
||||
content: line,
|
||||
tag: message.tag.clone(),
|
||||
message: whole.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.lines.truncate(100);
|
||||
count
|
||||
}
|
||||
/// What a message's lines wrap (ComponentRenderUtils.wrapComponents):
|
||||
/// without Chat Colors its `§` codes are stripped (stripColor); with
|
||||
@@ -410,6 +499,67 @@ impl Chat {
|
||||
content.strip_legacy()
|
||||
}
|
||||
}
|
||||
/// A message's lines before wrapping: [`Chat::displayed`], with PvP & HUD's time before it
|
||||
/// and count after it, in grey.
|
||||
fn message_text(&self, message: &Message) -> Text {
|
||||
let content = self.displayed(&message.content);
|
||||
if !self.qol.timestamps && message.repeats <= 1 {
|
||||
return content;
|
||||
}
|
||||
let mut out = Text::empty();
|
||||
if self.qol.timestamps {
|
||||
let (h, m) = message.time;
|
||||
let time = Text::literal(format!("{h:02}:{m:02}"));
|
||||
out = out.append(
|
||||
Text::translate_with_fallback("horizon.chat.timestamp", "[%s] ", &[time])
|
||||
.with_color(text::GRAY),
|
||||
);
|
||||
}
|
||||
out = out.append(content);
|
||||
if message.repeats > 1 {
|
||||
let count = Text::literal(message.repeats.to_string());
|
||||
out = out.append(
|
||||
Text::translate_with_fallback("horizon.chat.repeats", " (x%s)", &[count])
|
||||
.with_color(text::GRAY),
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
/// PvP & HUD's chat options; a change lays the lines out again.
|
||||
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
|
||||
if self.qol != qol {
|
||||
self.qol = qol;
|
||||
self.rewrap(&atlas.font);
|
||||
}
|
||||
}
|
||||
/// PvP & HUD's chat search: only the messages holding `query` (any case) show; `None` or
|
||||
/// an empty query shows them all again.
|
||||
pub fn set_search(&mut self, atlas: &HudAtlas, query: Option<&str>) {
|
||||
self.set_search_with(&atlas.font, query);
|
||||
}
|
||||
fn set_search_with(&mut self, font: &crate::font::Font, query: Option<&str>) {
|
||||
// Called each frame while searching: no work (nor allocation) for the same query.
|
||||
let blank = |q: &str| q.trim().is_empty();
|
||||
let same = match (&self.search, query) {
|
||||
(None, None) => true,
|
||||
(None, Some(q)) => blank(q),
|
||||
(Some(_), None) => false,
|
||||
(Some(current), Some(q)) => q.chars().flat_map(char::to_lowercase).eq(current.chars()),
|
||||
};
|
||||
if same {
|
||||
return;
|
||||
}
|
||||
let query = query
|
||||
.map(str::to_lowercase)
|
||||
.filter(|q| !q.trim().is_empty());
|
||||
if self.search != query {
|
||||
self.search = query;
|
||||
self.rewrap(font);
|
||||
}
|
||||
}
|
||||
pub fn searching(&self) -> bool {
|
||||
self.search.is_some()
|
||||
}
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
|
||||
/// keeping only the messages the Chat option shows.
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
@@ -419,16 +569,15 @@ impl Chat {
|
||||
self.scroll = 0;
|
||||
self.new_since_scroll = false;
|
||||
self.lines.clear();
|
||||
let messages: Vec<Message> = self
|
||||
.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|m| self.abilities.shows(m.source))
|
||||
.cloned()
|
||||
.collect();
|
||||
for m in &messages {
|
||||
self.add_lines(font, m, false);
|
||||
let mut messages = std::mem::take(&mut self.messages);
|
||||
for m in messages.iter_mut().rev() {
|
||||
m.wrapped = if self.abilities.shows(m.source) {
|
||||
self.add_lines(font, m, false)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
self.messages = messages;
|
||||
}
|
||||
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
|
||||
/// the messages it now hides leave the chat, those it shows again return.
|
||||
@@ -490,6 +639,9 @@ impl Chat {
|
||||
}
|
||||
*message = Message {
|
||||
added: message.added,
|
||||
time: message.time,
|
||||
repeats: 1,
|
||||
wrapped: 0,
|
||||
content: Self::deleted_marker(),
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
SYSTEM_INDICATOR,
|
||||
@@ -694,15 +846,42 @@ impl Chat {
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
if focused && self.abilities.any_restriction() {
|
||||
let restricted = focused && self.abilities.any_restriction();
|
||||
if restricted {
|
||||
let prompt = self.restricted_prompt(&atlas.font, batch.height as i32);
|
||||
hud_layout::paint(batch, atlas, &prompt);
|
||||
}
|
||||
if focused && self.search_prompt {
|
||||
let row = if restricted { 2 } else { 1 };
|
||||
let prompt = self.search_prompt(&atlas.font, batch.height as i32, row);
|
||||
hud_layout::paint(batch, atlas, &prompt);
|
||||
}
|
||||
}
|
||||
/// `ChatComponent.extractRenderState` for `FOREGROUND_RESTRICTED`: a
|
||||
/// background row and the red, underlined "Chat is restricted" line in
|
||||
/// the row above the messages, clipped to the chat's width.
|
||||
fn restricted_prompt(&self, font: &crate::font::Font, height: i32) -> Vec<GuiDraw> {
|
||||
let prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
|
||||
color: Some(text::RED),
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
});
|
||||
self.prompt_above(font, height, prompt, 1)
|
||||
}
|
||||
/// PvP & HUD's chat search: a yellow "Search chat:" line above the lines while the box holds
|
||||
/// the query.
|
||||
fn search_prompt(&self, font: &crate::font::Font, height: i32, row: i32) -> Vec<GuiDraw> {
|
||||
let label = Text::translate_with_fallback("horizon.chat.search", "Search chat:", &[]);
|
||||
self.prompt_above(font, height, label.with_color(text::YELLOW), row)
|
||||
}
|
||||
/// A line `row` lines above the shown ones, on the chat's background.
|
||||
fn prompt_above(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
prompt: Text,
|
||||
row: i32,
|
||||
) -> Vec<GuiDraw> {
|
||||
let o = &self.options;
|
||||
let scale = o.scale as f32;
|
||||
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
|
||||
@@ -715,12 +894,8 @@ impl Chat {
|
||||
.len()
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(true));
|
||||
let top = chat_bottom - (shown as i32 + 1) * entry_height;
|
||||
let mut prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
|
||||
color: Some(text::RED),
|
||||
underlined: Some(true),
|
||||
..Style::default()
|
||||
});
|
||||
let top = chat_bottom - (shown as i32 + row) * entry_height;
|
||||
let mut prompt = prompt;
|
||||
// ComponentRenderUtils.clipText.
|
||||
let mut width = 0.0;
|
||||
let fits = prompt
|
||||
@@ -765,6 +940,12 @@ impl Chat {
|
||||
) -> Option<ClickEvent> {
|
||||
self.style_at(font, height, x, y)?.click
|
||||
}
|
||||
/// The whole text of the message under a GUI-space cursor while chat is focused (PvP &
|
||||
/// HUD's copy on click), without its time or count.
|
||||
pub fn message_at(&self, height: i32, x: f64, y: f64) -> Option<&str> {
|
||||
let (line, x) = self.line_at(height, x, y)?;
|
||||
(x >= 0.0).then_some(&*line.message)
|
||||
}
|
||||
/// The hover text under a GUI-space cursor while chat is focused
|
||||
/// (ChatScreen.render's `renderComponentHoverEffect`).
|
||||
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
|
||||
|
||||
@@ -34,6 +34,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
added: 0,
|
||||
content,
|
||||
tag: None,
|
||||
message: "".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -51,6 +52,7 @@ fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
SYSTEM_INDICATOR,
|
||||
))),
|
||||
message: "hi".into(),
|
||||
});
|
||||
let text_alpha = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
@@ -701,3 +703,140 @@ fn a_chat_message_is_cut_to_the_256_utf16_units_the_packet_holds() {
|
||||
let mixed = format!("{}\u{1f600}", "b".repeat(255));
|
||||
assert_eq!(normalize_chat_message(&mixed), "b".repeat(255));
|
||||
}
|
||||
|
||||
/// PvP & HUD's chat options: the time before each message, and a repeated message counted on
|
||||
/// its own lines instead of filling the chat.
|
||||
#[test]
|
||||
fn timestamps_and_compact_chat_change_the_lines_not_the_messages() {
|
||||
let font = font();
|
||||
let mut chat = Chat {
|
||||
clock: Some((9, 5)),
|
||||
..Chat::default()
|
||||
};
|
||||
chat.qol.compact = true;
|
||||
let says = |chat: &mut Chat, text: &str| {
|
||||
chat.add_message(
|
||||
&font,
|
||||
Text::literal(text),
|
||||
None,
|
||||
ChatSource::Server,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
};
|
||||
let shown =
|
||||
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
|
||||
says(&mut chat, "gg");
|
||||
says(&mut chat, "gg");
|
||||
says(&mut chat, "gg");
|
||||
assert_eq!(shown(&chat), ["gg (x3)"]);
|
||||
says(&mut chat, "wp");
|
||||
says(&mut chat, "gg");
|
||||
assert_eq!(shown(&chat), ["gg", "wp", "gg (x3)"]);
|
||||
assert_eq!(chat.received, 5, "every message still counts as received");
|
||||
chat.qol.timestamps = true;
|
||||
chat.rewrap(&font);
|
||||
assert_eq!(
|
||||
shown(&chat),
|
||||
["[09:05] gg", "[09:05] wp", "[09:05] gg (x3)"]
|
||||
);
|
||||
// Copying takes the message as it came: no time, no count.
|
||||
assert_eq!(&*chat.lines[2].message, "gg");
|
||||
// Turned off, the history shows each repeat as it was counted.
|
||||
chat.qol = ChatQol::default();
|
||||
chat.rewrap(&font);
|
||||
assert_eq!(shown(&chat), ["gg", "wp", "gg (x3)"]);
|
||||
}
|
||||
|
||||
/// PvP & HUD's chat search: only the messages holding the query show, in any case; clearing
|
||||
/// it brings every line back.
|
||||
#[test]
|
||||
fn the_chat_search_keeps_the_matching_messages() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
for text in ["Alpha one", "beta", "ALPHABET soup"] {
|
||||
chat.add_message(
|
||||
&font,
|
||||
Text::literal(text),
|
||||
None,
|
||||
ChatSource::Server,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
let shown =
|
||||
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
|
||||
chat.set_search_with(&font, Some("alpha"));
|
||||
assert!(chat.searching());
|
||||
assert_eq!(shown(&chat), ["ALPHABET soup", "Alpha one"]);
|
||||
// A new message that doesn't match stays out of sight until the search ends.
|
||||
chat.add_message(
|
||||
&font,
|
||||
Text::literal("gamma"),
|
||||
None,
|
||||
ChatSource::Server,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
assert_eq!(shown(&chat).len(), 2);
|
||||
chat.set_search_with(&font, Some(" "));
|
||||
assert!(!chat.searching());
|
||||
assert_eq!(
|
||||
shown(&chat),
|
||||
["gamma", "ALPHABET soup", "beta", "Alpha one"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Search and copy read a message as the chat shows it: legacy `§` codes format it, they
|
||||
/// aren't text.
|
||||
#[test]
|
||||
fn the_chat_search_and_copy_skip_legacy_codes() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
for text in ["He§cllo", "§aplain"] {
|
||||
chat.add_message(
|
||||
&font,
|
||||
Text::literal(text),
|
||||
None,
|
||||
ChatSource::Server,
|
||||
0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert_eq!(&*chat.lines[1].message, "Hello");
|
||||
chat.set_search_with(&font, Some("hello"));
|
||||
assert_eq!(chat.lines.len(), 1);
|
||||
// `c` and `a` are codes here, not letters of either message.
|
||||
chat.set_search_with(&font, Some("c"));
|
||||
assert_eq!(chat.lines.len(), 0);
|
||||
}
|
||||
|
||||
/// A repeat merged by compact chat leaves a reader scrolled up where they were.
|
||||
#[test]
|
||||
fn a_compact_repeat_keeps_the_scrolled_view_still() {
|
||||
let font = font();
|
||||
let mut chat = Chat::default();
|
||||
chat.qol.compact = true;
|
||||
let says = |chat: &mut Chat, text: &str| {
|
||||
chat.add_message(
|
||||
&font,
|
||||
Text::literal(text),
|
||||
None,
|
||||
ChatSource::Server,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
};
|
||||
for i in 0..60 {
|
||||
says(&mut chat, &format!("line {i}"));
|
||||
}
|
||||
says(&mut chat, "gg");
|
||||
chat.scroll_by(5, true);
|
||||
assert_eq!(chat.scroll, 5);
|
||||
says(&mut chat, "gg");
|
||||
assert_eq!(chat.lines[0].content.plain(), "gg (x2)");
|
||||
assert_eq!(chat.scroll, 5);
|
||||
// A new message still pushes the view up by its line.
|
||||
says(&mut chat, "new");
|
||||
assert_eq!(chat.scroll, 6);
|
||||
}
|
||||
@@ -120,6 +120,16 @@ impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
self.blocks.may_reach_out(chunk, section_y)
|
||||
}
|
||||
fn section_is_empty(
|
||||
&mut self,
|
||||
chunk: Self::Chunk,
|
||||
section_y: i32,
|
||||
) -> std::result::Result<bool, Self::Error> {
|
||||
self.blocks.section_is_empty(chunk, section_y)
|
||||
}
|
||||
fn missing_chunks_stay_missing(&self) -> bool {
|
||||
self.blocks.missing_chunks_stay_missing()
|
||||
}
|
||||
fn context_shape(
|
||||
&mut self,
|
||||
block: CollisionBlock,
|
||||
@@ -424,6 +434,26 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
Ok(self.section_reaches_out(chunk, section_y))
|
||||
}
|
||||
/// From the section's palette: every block it can hold has an empty shape (air, plants).
|
||||
/// A palette of the whole registry, or a section outside the column, is read cell by cell.
|
||||
fn section_is_empty(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_some_and(|blocks| {
|
||||
!blocks.is_global()
|
||||
&& !blocks.maybe_has(|v| {
|
||||
self.binding
|
||||
.collision_block(BlockStateId(v))
|
||||
.map_or(true, |block| !block.empty_shape)
|
||||
})
|
||||
}))
|
||||
}
|
||||
/// The resident columns do not change while a scan runs, and looking one up changes nothing.
|
||||
fn missing_chunks_stay_missing(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
_: Self::Chunk,
|
||||
@@ -555,6 +585,9 @@ impl BlockCollisionWorld for EmptyCollisions {
|
||||
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
|
||||
Ok(None)
|
||||
}
|
||||
fn missing_chunks_stay_missing(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
|
||||
Err(io::Error::other("no chunk is resident"))
|
||||
}
|
||||
|
||||
@@ -608,7 +608,7 @@ pub struct DataStore {
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
@@ -617,7 +617,7 @@ pub struct DataStore {
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -625,7 +625,7 @@ fn content_id(file: &str) -> Option<String> {
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
|
||||
| "villager_trade" | "dialog" | "data_maps" => kind,
|
||||
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => kind,
|
||||
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
|
||||
// test:sub/mytag`), the whole path after the namespace, so nested
|
||||
// registries keep their directory (`TagLoader`'s id shape).
|
||||
@@ -656,6 +656,7 @@ impl DataStore {
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"damage_type",
|
||||
"data_maps",
|
||||
"dialog",
|
||||
"function",
|
||||
|
||||
@@ -87,7 +87,11 @@ fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
|
||||
// primed TNT flash (5) and at column overlay - 16 for 16..=31 (a creeper's
|
||||
// swelling). Alpha 1 changes nothing.
|
||||
fn overlay_texel(overlay: u32) -> vec4<f32> {
|
||||
if overlay == 1u || overlay == 8u { return HURT_OVERLAY; }
|
||||
if overlay == 1u || overlay == 8u {
|
||||
// PvP & HUD's hit colour (pvp.rs `Hit::texel`) rides in colored_count.y; 0 is vanilla's.
|
||||
let hit = globals.colored_count.y;
|
||||
return select(HURT_OVERLAY, unpack4x8unorm(hit), hit != 0u);
|
||||
}
|
||||
if overlay == 5u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[15]); }
|
||||
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
|
||||
return vec4(1.0);
|
||||
@@ -246,11 +250,18 @@ fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
|
||||
return vec2(scaled.x * c - scaled.y * s - offset0,
|
||||
scaled.x * s + scaled.y * c + offset1);
|
||||
}
|
||||
// PvP & HUD's glint colour (pvp.rs `Tweaks::glint_texel`, 0 for vanilla's): the sample's
|
||||
// brightest channel over the chosen colour, so the shimmer keeps its pattern and strength.
|
||||
fn tinted_glint(glint: vec4<f32>, packed: u32) -> vec4<f32> {
|
||||
if packed == 0u { return glint; }
|
||||
let tint = unpack4x8unorm(packed).rgb;
|
||||
return vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
|
||||
}
|
||||
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
|
||||
// is squared along with the sample.
|
||||
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
|
||||
let alpha = globals.fog_ends.w;
|
||||
let g = alpha * glint;
|
||||
let g = alpha * tinted_glint(glint, globals.colored_count.z);
|
||||
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
|
||||
}
|
||||
// CRUMBLING_UV_BEGIN
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Corner {
|
||||
Corner::BottomRight => "Bottom right",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str, default: Corner) -> Corner {
|
||||
pub fn parse(s: &str, default: Corner) -> Corner {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|c| c.name() == s)
|
||||
|
||||
@@ -212,8 +212,11 @@ pub struct FogInputs<'a> {
|
||||
pub boss_world_fog: bool,
|
||||
/// The camera entity is a spectator (`LavaFogEnvironment`).
|
||||
pub spectator: bool,
|
||||
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
|
||||
/// The camera entity has Fire Resistance (`LavaFogEnvironment`), or PvP & HUD's clear lava
|
||||
/// asks for its fog.
|
||||
pub fire_resistance: bool,
|
||||
/// PvP & HUD's clear water: the water vision `FogState` reaches after 600 ticks under water.
|
||||
pub full_water_vision: bool,
|
||||
/// `GameRenderer.nightVisionScale` while Night Vision is on without
|
||||
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
|
||||
pub night_vision: f32,
|
||||
@@ -224,7 +227,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
let rd = input.render_distance_chunks as f32 * 16.0;
|
||||
let sky = input.sky;
|
||||
let eye_in_water = input.fog_type == FogType::Water;
|
||||
let water_vision = state.water_vision(eye_in_water);
|
||||
let water_vision = if input.full_water_vision && eye_in_water {
|
||||
1.0
|
||||
} else {
|
||||
state.water_vision(eye_in_water)
|
||||
};
|
||||
// Base colour of the applicable environment.
|
||||
let mut color = match input.fog_type {
|
||||
FogType::Lava => rgb(0x99_1A00),
|
||||
@@ -451,6 +458,7 @@ mod tests {
|
||||
boss_world_fog: false,
|
||||
spectator: false,
|
||||
fire_resistance: false,
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
}
|
||||
}
|
||||
@@ -673,6 +681,31 @@ mod tests {
|
||||
assert_eq!(s.water_vision(false), 0.0);
|
||||
}
|
||||
#[test]
|
||||
fn clear_water_sees_as_far_as_after_thirty_seconds_under_water() {
|
||||
let sky = Sky::default();
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = FogType::Water;
|
||||
// Just in: vanilla's water vision is 0, so the fog ends at a quarter of 96 blocks.
|
||||
let mut state = FogState::default();
|
||||
state.tick(true);
|
||||
let first = setup_fog(&tables, &state, &input);
|
||||
assert_eq!(first.environmental_end, 24.0);
|
||||
for _ in 0..600 {
|
||||
state.tick(true);
|
||||
}
|
||||
let settled = setup_fog(&tables, &state, &input);
|
||||
assert_eq!(settled.environmental_end, 96.0);
|
||||
input.full_water_vision = true;
|
||||
let mut fresh = FogState::default();
|
||||
fresh.tick(true);
|
||||
let clear = setup_fog(&tables, &fresh, &input);
|
||||
assert_eq!(clear.environmental_end, settled.environmental_end);
|
||||
assert_eq!(clear.color, settled.color);
|
||||
}
|
||||
#[test]
|
||||
fn rain_fog_pulls_fog_in() {
|
||||
let sky = Sky {
|
||||
fog_end: 1024.0,
|
||||
|
||||
@@ -209,6 +209,7 @@ mod view;
|
||||
mod visbuffer;
|
||||
mod wgsl_prefix;
|
||||
mod world_post;
|
||||
pub mod world_primitives;
|
||||
|
||||
/// A section's instance: its camera-relative origin and, in `w`, the step of
|
||||
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
|
||||
@@ -883,6 +884,8 @@ pub struct Frame<'a> {
|
||||
pub outline_after_terrain: bool,
|
||||
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
|
||||
pub outline_high_contrast: bool,
|
||||
/// The PvP & HUD page's outline and hit colour (`pvp::Frame`; default: vanilla's).
|
||||
pub pvp: crate::pvp::Frame,
|
||||
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
|
||||
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
|
||||
/// over everything.
|
||||
@@ -933,6 +936,9 @@ pub struct Frame<'a> {
|
||||
pub additive: &'a [Vertex],
|
||||
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
|
||||
pub see_through: &'a [Vertex],
|
||||
/// Native mods' lines, boxes, beams and text (`native_world_primitives`), drawn at the end
|
||||
/// of the world pass, before the first-person hand.
|
||||
pub world_primitives: world_primitives::Batch<'a>,
|
||||
/// Opaque entity quads only a native pack's shadow pass draws (the local player in first
|
||||
/// person).
|
||||
pub shadow_only: &'a [Vertex],
|
||||
@@ -1158,6 +1164,8 @@ pub struct Renderer {
|
||||
lightning_pipeline: wgpu::RenderPipeline,
|
||||
lightning: lightning::Renderer,
|
||||
see_through_pipeline: wgpu::RenderPipeline,
|
||||
/// Native mods' world primitives (`Frame::world_primitives`).
|
||||
world_primitives: world_primitives::WorldPrimitives,
|
||||
text_polygon_offset_pipeline: wgpu::RenderPipeline,
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
@@ -4455,6 +4463,7 @@ impl Renderer {
|
||||
on_off(gpu_timer.is_some()),
|
||||
));
|
||||
}
|
||||
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
|
||||
Ok(Self {
|
||||
display: DisplayWatch::default(),
|
||||
display_fresh: false,
|
||||
@@ -4542,6 +4551,7 @@ impl Renderer {
|
||||
lightning_pipeline,
|
||||
lightning: lightning::Renderer::new(),
|
||||
see_through_pipeline,
|
||||
world_primitives,
|
||||
text_polygon_offset_pipeline,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
@@ -7252,8 +7262,10 @@ impl Renderer {
|
||||
},
|
||||
colored_count: [
|
||||
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
|
||||
0,
|
||||
0,
|
||||
// PvP & HUD's hit colour: entity.wgsl's hurt texel, 0 for vanilla's.
|
||||
frame.pvp.hurt_texel,
|
||||
// PvP & HUD's glint colour: entity.wgsl's `tinted_glint`, 0 for vanilla's.
|
||||
frame.pvp.glint,
|
||||
0,
|
||||
],
|
||||
emissive_layers: {
|
||||
@@ -8118,6 +8130,19 @@ impl Renderer {
|
||||
frame.weather,
|
||||
frame.weather_time,
|
||||
);
|
||||
// Native mods' world primitives, drawn as the world is: its jittered projection, the
|
||||
// line widths in the frame's pixels as the gizmo lines'.
|
||||
let world_primitives = !frame.title && {
|
||||
let (uploads, encoder) = (&mut self.uploads, &mut encoder);
|
||||
self.world_primitives.prepare(
|
||||
&self.device,
|
||||
&mut |buffer, bytes| uploads.buffer(encoder, buffer, 0, bytes),
|
||||
&frame.world_primitives,
|
||||
jittered,
|
||||
[self.config.width, self.config.height],
|
||||
&self.entity_atlas,
|
||||
)
|
||||
};
|
||||
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
|
||||
if outline_edges > 0 {
|
||||
// The same per-line colour and pixel width layout as the gizmo lines. A high
|
||||
@@ -8133,12 +8158,14 @@ impl Renderer {
|
||||
line
|
||||
})
|
||||
};
|
||||
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
|
||||
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
|
||||
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
|
||||
stroke(0xff00_0000, 7.0)
|
||||
.chain(stroke(0xff57_ffe1, width))
|
||||
stroke(0xff00_0000, 7.0 * scale)
|
||||
.chain(stroke(custom.unwrap_or(0xff57_ffe1), width * scale))
|
||||
.collect()
|
||||
} else {
|
||||
stroke(0x6600_0000, width).collect()
|
||||
stroke(custom.unwrap_or(0x6600_0000), width * scale).collect()
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
|
||||
@@ -10039,6 +10066,11 @@ impl Renderer {
|
||||
0..1,
|
||||
);
|
||||
}
|
||||
// Native mods' primitives: after the entities and the rest of the world, into its
|
||||
// targets (its resolution, reversed depth and TAA), before the hand.
|
||||
if world_primitives {
|
||||
self.world_primitives.draw(&mut pass);
|
||||
}
|
||||
if hand_in_world {
|
||||
pass.set_pipeline(&self.depth_reset);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
|
||||
@@ -517,6 +517,7 @@ impl Scene {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
@@ -548,6 +549,7 @@ impl Scene {
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
shadow_only: &[],
|
||||
celestial: &self.celestial,
|
||||
stars: f
|
||||
|
||||
@@ -173,7 +173,8 @@ struct HorizonMaterialSolid {
|
||||
@location(0) color: vec4<f32>,
|
||||
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
|
||||
// stage's `surface_at` compares with the final depth.
|
||||
@location(1) surface: vec4<f32>,
|
||||
// Its four floats' bits (`SURFACE_FORMAT` is an integer format).
|
||||
@location(1) surface: vec4<u32>,
|
||||
}
|
||||
// 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.
|
||||
@@ -209,7 +210,7 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
var out: HorizonMaterialSolid;
|
||||
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);
|
||||
out.surface = bitcast<vec4<u32>>(vec4(horizon_surface.xyz, in.clip.z));
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
@@ -335,9 +336,12 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
|
||||
}
|
||||
";
|
||||
|
||||
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
|
||||
/// bits), y smoothness, z the pack's data, w the fragment's depth.
|
||||
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
/// The surface target (the post stage's `scene_surface`): the bits of four floats, x the normal
|
||||
/// (two snorm16 in its bits), y smoothness, z the pack's data, w the fragment's depth. Stored
|
||||
/// as unsigned integers, not `Rgba32Float`: RGBA32UI is colour-renderable on every backend,
|
||||
/// where OpenGL (GLES 3 semantics) refuses RGBA32F as a render attachment, and integers keep
|
||||
/// the packed normal's bits exact.
|
||||
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Uint;
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`, and the vertex stage in its module when `vertex`.
|
||||
|
||||
@@ -687,7 +687,7 @@ impl NativePost {
|
||||
texture_entry(8, float),
|
||||
texture_entry(9, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(10, float),
|
||||
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
texture_entry(11, wgpu::TextureSampleType::Uint),
|
||||
];
|
||||
entries.extend(pack_texture_entries(frame.textures.len()));
|
||||
entries
|
||||
@@ -1547,4 +1547,306 @@ file = \"b.wgsl\"
|
||||
"{fourth:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// OpenGL refuses `Rgba32Float` as a render attachment (GLES 3 semantics, absent
|
||||
/// `EXT_color_buffer_float`) but always renders to the integer formats, so the surface
|
||||
/// is an integer format, and `surface_at` gets the floats back from the stored bits.
|
||||
#[test]
|
||||
fn the_surface_is_an_integer_target_that_the_post_stage_reads_bit_exact() {
|
||||
assert_eq!(
|
||||
super::super::native_material::SURFACE_FORMAT.sample_type(None, None),
|
||||
Some(wgpu::TextureSampleType::Uint)
|
||||
);
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
|
||||
[[post]]\nfile = \"a.wgsl\"\n";
|
||||
// The surface's fields on the colour: normal x, smoothness, data, and 1 when present.
|
||||
let a = "fn post(input: PostInput) -> vec4<f32> {\n \
|
||||
let s = surface_at(vec2<i32>(input.pixel));\n \
|
||||
return vec4<f32>(s.normal.x, s.smoothness, s.data, select(0.0, 1.0, s.present));\n}\n";
|
||||
let files = [(MANIFEST, manifest), ("a.wgsl", a)]
|
||||
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
|
||||
.into();
|
||||
let report = check(&Pack::from_files(files).unwrap()).unwrap();
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
|
||||
let mut post = NativePost::new(
|
||||
&device,
|
||||
report.stages,
|
||||
&report.manifest,
|
||||
&[],
|
||||
format,
|
||||
&frame,
|
||||
)
|
||||
.unwrap();
|
||||
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = texture(&device, format, usage).create_view(&Default::default());
|
||||
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
|
||||
.create_view(&Default::default());
|
||||
let screen = texture(
|
||||
&device,
|
||||
format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let view = screen.create_view(&Default::default());
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
// What the material stage stores, as the bits of floats: the normal +x (octahedral
|
||||
// (1, 0), two snorm16), smoothness 0.5, data 0.25 and the fragment's depth 0.5.
|
||||
let writer = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(f32((i << 1u) & 2u) * 2.0 - 1.0, f32(i & 2u) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs() -> @location(0) vec4<u32> {
|
||||
return bitcast<vec4<u32>>(vec4<f32>(
|
||||
bitcast<f32>(pack2x16snorm(vec2<f32>(1.0, 0.0))), 0.5, 0.25, 0.5));
|
||||
}"
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let writer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &writer,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &writer,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: super::super::native_material::SURFACE_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let mut read = |depth_of_world: f64| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &frame.surface_view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&writer);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(depth_of_world as f32),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
post.run(
|
||||
&device,
|
||||
&mut encoder,
|
||||
(&color, &depth, &frame.surface_view),
|
||||
&view,
|
||||
[4, 4],
|
||||
true,
|
||||
None,
|
||||
);
|
||||
encoder.copy_texture_to_buffer(
|
||||
screen.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
|
||||
rows_per_image: Some(4),
|
||||
},
|
||||
},
|
||||
screen.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
|
||||
buffer.unmap();
|
||||
pixel
|
||||
};
|
||||
// Present where the world's depth is the stored one: +x, 0.5, 0.25 as 8-bit.
|
||||
assert_eq!(read(0.5), [255, 128, 64, 255]);
|
||||
// Hidden by something nearer drawn since: the stored values no longer show.
|
||||
assert_eq!(read(0.25), [0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
/// The surface holds float bit patterns, some of which are NaNs (a normal's two snorm16
|
||||
/// can form one). The material stage writes `bitcast<vec4<u32>>(vec4<f32>(..))`; this
|
||||
/// runs that same expression (not the whole material fragment, which needs a full terrain
|
||||
/// pipeline) on bits fed as integers and checks the texture holds them exactly: payloads,
|
||||
/// signs and denormals included.
|
||||
#[test]
|
||||
fn the_surface_keeps_every_bit_of_the_floats_written_through_the_material_stage_bitcast() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let cases: [[u32; 4]; 4] = [
|
||||
[0x7fc0_1234, 0xffbf_ffff, 0x7fff_8001, 0x7f80_0001],
|
||||
[0x8000_0000, 0x0000_0001, 0x007f_ffff, 0x8000_0001],
|
||||
[0xffff_ffff, 0x7f80_0000, 0xff80_0000, 0x3f80_0000],
|
||||
[0x7fff_8001, 0x0000_0000, 0x1234_5678, 0xdead_beef],
|
||||
];
|
||||
let mut bits = [[0u32; 4]; 16];
|
||||
bits[..4].copy_from_slice(&cases);
|
||||
let inputs = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(&inputs, 0, bytemuck::cast_slice(&bits));
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
"@group(0) @binding(0) var<uniform> bits: array<vec4<u32>, 16>;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
return vec4<f32>(f32((i << 1u) & 2u) * 2.0 - 1.0, f32(i & 2u) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<u32> {
|
||||
let v = bitcast<vec4<f32>>(bits[u32(p.y) * 4u + u32(p.x)]);
|
||||
return bitcast<vec4<u32>>(v);
|
||||
}"
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: super::super::native_material::SURFACE_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: inputs.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let surface = texture(
|
||||
&device,
|
||||
super::super::native_material::SURFACE_FORMAT,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let row = u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: row * 4,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let view = surface.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
surface.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
|
||||
rows_per_image: Some(4),
|
||||
},
|
||||
},
|
||||
surface.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = buffer.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
buffer.unmap();
|
||||
for (i, expected) in bits.iter().enumerate() {
|
||||
let (x, y) = (i % 4, i / 4);
|
||||
let at = y * row as usize + x * 16;
|
||||
let got: [u32; 4] = std::array::from_fn(|c| {
|
||||
u32::from_ne_bytes(data[at + c * 4..at + c * 4 + 4].try_into().unwrap())
|
||||
});
|
||||
assert_eq!(
|
||||
got.map(|v| format!("{v:08x}")),
|
||||
expected.map(|v| format!("{v:08x}")),
|
||||
"texel {x},{y}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -614,6 +614,13 @@ impl PackAtlas {
|
||||
"vec4(0.0)"
|
||||
};
|
||||
let attributes = [
|
||||
// section_instance stores 1 - visibility in a positive origin's mantissa.
|
||||
// Negative origins encode a distant-terrain handover, fully visible here
|
||||
// just as terrain.wgsl's instance_visibility treats them.
|
||||
(
|
||||
"mc_chunkFade",
|
||||
"(hz_origin.w > 0.0 ? 1.0 - float(floatBitsToUint(hz_origin.w) & 0x7fffffu) / 8388608.0 : 1.0)",
|
||||
),
|
||||
("mc_Entity", entity),
|
||||
("at_midBlock", mid_block),
|
||||
(
|
||||
@@ -641,6 +648,10 @@ impl PackAtlas {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_atlas/fade_test.rs"]
|
||||
mod fade_test;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_atlas/midpoint_test.rs"]
|
||||
mod midpoint_test;
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
//! Original pack-to-pixel controls of terrain's visibility input, with no external fixture.
|
||||
use super::PackAtlas;
|
||||
use crate::gfx::{section_instance, section_visibility};
|
||||
use crate::mesher::TerrainVertex;
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
struct ProbePack(PathBuf);
|
||||
impl Drop for ProbePack {
|
||||
fn drop(&mut self) {
|
||||
std::fs::remove_dir_all(&self.0).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
|
||||
Ok(adapter) => adapter,
|
||||
Err(error) => {
|
||||
eprintln!("SKIP terrain fade GPU control: no adapter: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
eprintln!("terrain fade adapter: {:?}", adapter.get_info());
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 2,
|
||||
height: 2,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let mut atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
|
||||
atlas.separate_ao = true;
|
||||
queue.submit([encoder.finish()]);
|
||||
let folder = ProbePack(
|
||||
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
|
||||
);
|
||||
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
|
||||
// The guard, initializer, varying and integer output are independent probe code.
|
||||
let vertex = "#version 330\nout vec4 observation;\n\
|
||||
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
|
||||
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
|
||||
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
|
||||
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
|
||||
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
|
||||
void main() { pixel = floatBitsToUint(observation); }";
|
||||
let geometry_vertex = vertex
|
||||
.replace("float first = mc_chunkFade;", "")
|
||||
.replace("mc_chunkFade, first,", "mc_chunkFade, mc_chunkFade,")
|
||||
.replace("observation", "vertex_observation");
|
||||
let geometry_shader = "#version 330\nlayout(triangles) in;\n\
|
||||
layout(triangle_strip, max_vertices=3) out;\nin vec4 vertex_observation[];\n\
|
||||
out vec4 observation;\nconst float mc_chunkFade = 0.8125;\nvoid main() {\n\
|
||||
for (int k=0; k<3; ++k) {\nobservation = vertex_observation[k];\n\
|
||||
observation.y = mc_chunkFade;\n\
|
||||
gl_Position = vec4(k == 1 ? 3.0 : -1.0, k == 2 ? 3.0 : -1.0, 0.0, 1.0);\n\
|
||||
EmitVertex();\n}\nEndPrimitive();\n}";
|
||||
for name in ["gbuffers_terrain", "gbuffers_water", "shadow"] {
|
||||
for (extension, code) in [("vsh", vertex), ("fsh", fragment)] {
|
||||
std::fs::write(folder.0.join(format!("shaders/{name}.{extension}")), code).unwrap();
|
||||
}
|
||||
}
|
||||
let uploaded = Instant::now();
|
||||
let cases = [
|
||||
("first frame", Some(&uploaded), 0, 1000, 0, 0.0),
|
||||
("137ms", Some(&uploaded), 137, 1000, 0, 0.137),
|
||||
("midpoint", Some(&uploaded), 500, 1000, 0, 0.5),
|
||||
("finished", Some(&uploaded), 1000, 1000, 0, 1.0),
|
||||
("disabled", Some(&uploaded), 0, 0, 0, 1.0),
|
||||
("no upload transition", None, 0, 1000, 0, 1.0),
|
||||
("distant handover", Some(&uploaded), 500, 1000, 17, 1.0),
|
||||
];
|
||||
let mut count = 0;
|
||||
for requested in [false, true] {
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/shaders.properties"),
|
||||
if requested {
|
||||
"shadow.enabled=true\niris.features.optional=FADE_VARIABLE\niris.features.required=FADE_VARIABLE\n"
|
||||
} else {
|
||||
"shadow.enabled=true\n"
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, notes) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
assert!(notes.is_empty(), "{notes:?}");
|
||||
for flat in [false, true] {
|
||||
for geometry in [false, true].into_iter().filter(|g| !*g || flat) {
|
||||
let geometry_path = folder.0.join("shaders/shadow.gsh");
|
||||
if geometry {
|
||||
std::fs::write(&geometry_path, geometry_shader).unwrap();
|
||||
} else if geometry_path.exists() {
|
||||
std::fs::remove_file(&geometry_path).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/shadow.vsh"),
|
||||
if geometry {
|
||||
geometry_vertex.as_str()
|
||||
} else {
|
||||
vertex
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let post = horizon_shaders::load_post(
|
||||
&pack,
|
||||
"",
|
||||
&properties,
|
||||
¯os,
|
||||
&horizon_shaders::GameInputs {
|
||||
terrain: Some(atlas.terrain_inputs(flat)),
|
||||
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
|
||||
inputs: atlas.terrain_inputs(true),
|
||||
pull: super::PULL.to_owned(),
|
||||
index: "@".to_owned(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
for (name, program) in [
|
||||
("terrain", &post.terrain),
|
||||
("water", &post.water),
|
||||
("shadow", &post.shadow),
|
||||
] {
|
||||
if geometry && name != "shadow" {
|
||||
continue;
|
||||
}
|
||||
let program = program
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{name}: {:?}", post.notes));
|
||||
for &(case, timestamp, elapsed, fade_ms, handover, expected) in &cases {
|
||||
let visibility = section_visibility(
|
||||
timestamp,
|
||||
fade_ms,
|
||||
uploaded + Duration::from_millis(elapsed),
|
||||
);
|
||||
let origin = section_instance([48.0, 0.0, -16.0], handover, visibility);
|
||||
let pixel = sample(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
program.program.as_ref().unwrap(),
|
||||
flat,
|
||||
geometry,
|
||||
origin,
|
||||
);
|
||||
let fade = if requested { expected } else { 1.0 };
|
||||
let expected = [
|
||||
fade,
|
||||
if geometry { 0.8125 } else { fade },
|
||||
128.0 / 255.0,
|
||||
0.25,
|
||||
];
|
||||
assert!(
|
||||
pixel
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.all(|(a, b)| a.is_finite() && (a - b).abs() < 2e-7),
|
||||
"{name} {case} flat={flat} geometry={geometry} requested={requested}: {pixel:?} != {expected:?}"
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("terrain fade pack-to-pixel cases executed: {count}");
|
||||
assert_eq!(count, 98);
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
atlas: &PackAtlas,
|
||||
program: &horizon_shaders::Program,
|
||||
flat: bool,
|
||||
geometry: bool,
|
||||
origin: [f32; 4],
|
||||
) -> [f32; 4] {
|
||||
let buffer = |bytes: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytes,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
let uniform = buffer(
|
||||
&vec![0; program.interface.uniform_size as usize],
|
||||
wgpu::BufferUsages::UNIFORM,
|
||||
);
|
||||
let origins = buffer(
|
||||
bytemuck::bytes_of(&origin),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
|
||||
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
|
||||
.into_iter()
|
||||
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
|
||||
.collect();
|
||||
let data = buffer(
|
||||
bytemuck::cast_slice(&vertices),
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
|
||||
);
|
||||
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
|
||||
let rects = buffer(
|
||||
bytemuck::cast_slice(&atlas.rects_table()),
|
||||
wgpu::BufferUsages::STORAGE,
|
||||
);
|
||||
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
|
||||
binding: n,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: if storage {
|
||||
wgpu::BufferBindingType::Storage { read_only: true }
|
||||
} else {
|
||||
wgpu::BufferBindingType::Uniform
|
||||
},
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let entries = [
|
||||
vec![binding(0, false)],
|
||||
if flat {
|
||||
vec![binding(0, true), binding(1, true)]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
vec![],
|
||||
if flat {
|
||||
vec![binding(0, true), binding(1, true), binding(2, true)]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
];
|
||||
let layouts: Vec<_> = entries
|
||||
.iter()
|
||||
.map(|entries| {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: None,
|
||||
entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let bindings = [
|
||||
vec![(0, &uniform)],
|
||||
if flat {
|
||||
vec![(0, &slots), (1, &origins)]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
vec![],
|
||||
if flat {
|
||||
vec![(0, &ids), (1, &data), (2, &rects)]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
];
|
||||
let groups: Vec<_> = layouts
|
||||
.iter()
|
||||
.zip(bindings)
|
||||
.map(|(layout, buffers)| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout,
|
||||
entries: &buffers
|
||||
.into_iter()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: buffer.as_entire_binding(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
|
||||
..Default::default()
|
||||
});
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let vs = module(program.vertex.clone());
|
||||
let fs = module(program.fragment.clone());
|
||||
let vertices = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &crate::gfx::TERRAIN_ATTRIBUTES,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 16,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &wgpu::vertex_attr_array![5 => Float32x4],
|
||||
}),
|
||||
];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &vertices[..if flat { 1 } else { 2 }],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: if geometry {
|
||||
wgpu::PrimitiveTopology::TriangleList
|
||||
} else {
|
||||
wgpu::PrimitiveTopology::PointList
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba32Uint,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let output = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba32Uint,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let view = output.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
for (n, group) in groups.iter().enumerate() {
|
||||
pass.set_bind_group(n as u32, group, &[]);
|
||||
}
|
||||
pass.set_vertex_buffer(0, data.slice(..));
|
||||
if !flat {
|
||||
pass.set_vertex_buffer(1, origins.slice(..));
|
||||
}
|
||||
pass.draw(0..if geometry { 3 } else { 1 }, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
output.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: Default::default(),
|
||||
},
|
||||
output.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.map_async(wgpu::MapMode::Read, .., |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
*bytemuck::from_bytes(&read.get_mapped_range(..).unwrap())
|
||||
}
|
||||
@@ -678,6 +678,43 @@ fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// `format` where `renders` says it can be a colour target, else the nearest float format that can:
|
||||
/// the same channels at 16 bits, then `Rgba8Unorm`. On OpenGL the 32-bit and 16-bit float formats
|
||||
/// render only with `GL_EXT_color_buffer_float`/`half_float`, and wgpu aborts on creating a target
|
||||
/// without. Other formats pass through. Float colour buffers are only sampled (never blended), so
|
||||
/// RENDER_ATTACHMENT is the only usage that decides.
|
||||
fn renderable_float(
|
||||
format: wgpu::TextureFormat,
|
||||
renders: impl Fn(wgpu::TextureFormat) -> bool,
|
||||
) -> wgpu::TextureFormat {
|
||||
use wgpu::TextureFormat as T;
|
||||
let half = match format {
|
||||
T::R32Float | T::R16Float => T::R16Float,
|
||||
T::Rg32Float | T::Rg16Float => T::Rg16Float,
|
||||
T::Rgba32Float | T::Rgba16Float => T::Rgba16Float,
|
||||
_ => return format,
|
||||
};
|
||||
if renders(format) {
|
||||
format
|
||||
} else if renders(half) {
|
||||
half
|
||||
} else {
|
||||
T::Rgba8Unorm
|
||||
}
|
||||
}
|
||||
|
||||
/// [`texture_format`] made renderable on this adapter (see [`renderable_float`]).
|
||||
fn target_format(
|
||||
f: Format,
|
||||
features: wgpu::Features,
|
||||
adapter: &wgpu::Adapter,
|
||||
) -> wgpu::TextureFormat {
|
||||
renderable_float(texture_format(f, features), |format| {
|
||||
(adapter.get_texture_format_features(format).allowed_usages)
|
||||
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
|
||||
})
|
||||
}
|
||||
|
||||
/// The SNORM wgpu format of SNORM pack format `f`, where this adapter can render to it. Iris
|
||||
/// stores such a buffer clamped to [-1, 1] and quantised to its bits; elsewhere the half floats of
|
||||
/// [`texture_format`] stand in.
|
||||
@@ -1891,7 +1928,8 @@ impl PackPost {
|
||||
let features = device.features();
|
||||
let formats: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
|
||||
let f = post.config.buffers[i].format;
|
||||
snorm_format(f, features, adapter).unwrap_or_else(|| texture_format(f, features))
|
||||
snorm_format(f, features, adapter)
|
||||
.unwrap_or_else(|| target_format(f, features, adapter))
|
||||
});
|
||||
let sky_clear = Some(&post.config.buffers[0])
|
||||
.filter(|b| b.clear)
|
||||
@@ -1944,7 +1982,9 @@ impl PackPost {
|
||||
// buffers 0 to 7.
|
||||
let shadow_formats: Option<[wgpu::TextureFormat; SHADOW_COLORS]> =
|
||||
post.shadow_pass.then(|| {
|
||||
std::array::from_fn(|n| texture_format(shadow_config.colors[n].format, features))
|
||||
std::array::from_fn(|n| {
|
||||
target_format(shadow_config.colors[n].format, features, adapter)
|
||||
})
|
||||
});
|
||||
let shadow_space: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
|
||||
shadow_formats
|
||||
@@ -5284,6 +5324,27 @@ mod tests {
|
||||
assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_targets_fall_back_when_not_renderable() {
|
||||
let all = |_| true;
|
||||
assert_eq!(renderable_float(T::Rgba32Float, all), T::Rgba32Float);
|
||||
// OpenGL without GL_EXT_color_buffer_float: only half floats render.
|
||||
let half_only = |f: T| matches!(f, T::R16Float | T::Rg16Float | T::Rgba16Float);
|
||||
assert_eq!(renderable_float(T::Rgba32Float, half_only), T::Rgba16Float);
|
||||
assert_eq!(renderable_float(T::Rg32Float, half_only), T::Rg16Float);
|
||||
assert_eq!(renderable_float(T::R32Float, half_only), T::R16Float);
|
||||
assert_eq!(renderable_float(T::Rgba16Float, half_only), T::Rgba16Float);
|
||||
// Neither renders: the 8-bit last resort.
|
||||
assert_eq!(renderable_float(T::Rgba32Float, |_| false), T::Rgba8Unorm);
|
||||
assert_eq!(renderable_float(T::Rg16Float, |_| false), T::Rgba8Unorm);
|
||||
// Other formats are never replaced.
|
||||
assert_eq!(renderable_float(T::Rgba8Uint, |_| false), T::Rgba8Uint);
|
||||
assert_eq!(
|
||||
renderable_float(T::Rgb10a2Unorm, |_| false),
|
||||
T::Rgb10a2Unorm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samplers_read_custom_textures_of_their_type() {
|
||||
use wgpu::TextureSampleType as T;
|
||||
|
||||
@@ -595,6 +595,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
@@ -618,6 +619,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
|
||||
@@ -438,6 +438,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
@@ -461,6 +462,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
|
||||
@@ -328,6 +328,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
@@ -351,6 +352,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
|
||||
@@ -1447,6 +1447,7 @@ mod initial_history_tests {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
@@ -1470,6 +1471,7 @@ mod initial_history_tests {
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
//! Native mods' world primitives (`native_world_primitives`, docs/native-mods.md): lines, boxes,
|
||||
//! beams and text a mod hands the game each frame, drawn here with Horizon's own pipelines at
|
||||
//! the end of the world pass, after the entities and before the first-person hand. They draw
|
||||
//! into the world's targets, so they take the world's reversed depth, its draw resolution and
|
||||
//! TAA's jitter; a mod never touches wgpu for them.
|
||||
|
||||
use super::Vertex;
|
||||
use glam::Mat4;
|
||||
|
||||
/// A frame's primitives, camera-relative, as the game tessellated them: quads of 4 vertices
|
||||
/// from the shared atlas (plain colours on its white texel, text on its glyphs, beams on the
|
||||
/// beacon beam sprite) and lines as `gizmos::camera_relative` gives them. `*_through` ones are
|
||||
/// seen through walls: no depth test.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Batch<'a> {
|
||||
pub quads: &'a [Vertex],
|
||||
pub quads_through: &'a [Vertex],
|
||||
pub lines: &'a [[f32; 8]],
|
||||
pub lines_through: &'a [[f32; 8]],
|
||||
}
|
||||
|
||||
impl Batch<'_> {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.quads.is_empty()
|
||||
&& self.quads_through.is_empty()
|
||||
&& self.lines.is_empty()
|
||||
&& self.lines_through.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Globals {
|
||||
view_proj: [[f32; 4]; 4],
|
||||
viewport: [f32; 4],
|
||||
}
|
||||
|
||||
/// The pipelines and buffers that draw a [`Batch`].
|
||||
pub struct WorldPrimitives {
|
||||
/// Depth tested, then seen through walls.
|
||||
quad_pipelines: [wgpu::RenderPipeline; 2],
|
||||
line_pipelines: [wgpu::RenderPipeline; 2],
|
||||
layout: wgpu::BindGroupLayout,
|
||||
globals: wgpu::Buffer,
|
||||
sampler: wgpu::Sampler,
|
||||
quads: wgpu::Buffer,
|
||||
lines: wgpu::Buffer,
|
||||
indices: wgpu::Buffer,
|
||||
index_quads: u32,
|
||||
group: Option<wgpu::BindGroup>,
|
||||
/// Quads depth tested, quads through walls, lines depth tested, lines through walls.
|
||||
counts: [u32; 4],
|
||||
}
|
||||
|
||||
/// Writes bytes into a buffer as part of the frame (the renderer's staging uploads, or the
|
||||
/// queue in a test).
|
||||
pub type Upload<'u> = &'u mut dyn FnMut(&wgpu::Buffer, &[u8]);
|
||||
|
||||
impl WorldPrimitives {
|
||||
/// Pipelines drawing into `color_format` over a reversed Depth32Float depth.
|
||||
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("World primitives"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("world_primitives.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("World primitives"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("World primitives"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let quad_attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint32];
|
||||
let line_attributes =
|
||||
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32];
|
||||
let quad_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &quad_attributes,
|
||||
})];
|
||||
let line_buffers = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 32,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &line_attributes,
|
||||
})];
|
||||
let pipeline = |label: &str,
|
||||
entry: [&str; 2],
|
||||
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
|
||||
topology,
|
||||
through: bool| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry[0]),
|
||||
compilation_options: Default::default(),
|
||||
buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology,
|
||||
// Seen from inside or behind as well: a box around the camera, a beam.
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
// Blended over what is drawn, writing no depth: a translucent box doesn't hide
|
||||
// the primitives behind it. Reversed depth: nearer is greater.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(if through {
|
||||
wgpu::CompareFunction::Always
|
||||
} else {
|
||||
wgpu::CompareFunction::GreaterEqual
|
||||
}),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry[1]),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let quads = ["vs_quad", "fs_quad"];
|
||||
let lines = ["vs_line", "fs_line"];
|
||||
let list = wgpu::PrimitiveTopology::TriangleList;
|
||||
let strip = wgpu::PrimitiveTopology::TriangleStrip;
|
||||
let quad_pipelines = [
|
||||
pipeline("World primitives", quads, &quad_buffers, list, false),
|
||||
pipeline(
|
||||
"World primitives through walls",
|
||||
quads,
|
||||
&quad_buffers,
|
||||
list,
|
||||
true,
|
||||
),
|
||||
];
|
||||
let line_pipelines = [
|
||||
pipeline("World primitive lines", lines, &line_buffers, strip, false),
|
||||
pipeline(
|
||||
"World primitive lines through walls",
|
||||
lines,
|
||||
&line_buffers,
|
||||
strip,
|
||||
true,
|
||||
),
|
||||
];
|
||||
let buffer = |label, size, usage| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
usage: usage | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
quad_pipelines,
|
||||
line_pipelines,
|
||||
globals: buffer(
|
||||
"World primitive globals",
|
||||
std::mem::size_of::<Globals>() as u64,
|
||||
wgpu::BufferUsages::UNIFORM,
|
||||
),
|
||||
sampler: device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("World primitives"),
|
||||
..Default::default()
|
||||
}),
|
||||
layout,
|
||||
quads: buffer("World primitive quads", 0, wgpu::BufferUsages::VERTEX),
|
||||
lines: buffer("World primitive lines", 0, wgpu::BufferUsages::VERTEX),
|
||||
indices: buffer("World primitive indices", 0, wgpu::BufferUsages::INDEX),
|
||||
index_quads: 0,
|
||||
group: None,
|
||||
counts: [0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads this frame's `batch`, drawn with `view_proj` (camera-relative world to clip,
|
||||
/// jittered as the world is) into a frame `viewport` pixels big, sampling `atlas`. Whether
|
||||
/// there is anything to draw.
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
upload: Upload<'_>,
|
||||
batch: &Batch<'_>,
|
||||
view_proj: Mat4,
|
||||
viewport: [u32; 2],
|
||||
atlas: &wgpu::TextureView,
|
||||
) -> bool {
|
||||
let quads = [batch.quads, batch.quads_through].map(|q| (q.len() / 4) as u32);
|
||||
let lines = [batch.lines, batch.lines_through].map(|l| l.len() as u32);
|
||||
self.counts = [quads[0], quads[1], lines[0], lines[1]];
|
||||
if batch.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let grow = |buffer: &mut wgpu::Buffer, bytes: u64| {
|
||||
if buffer.size() < bytes {
|
||||
*buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("World primitives"),
|
||||
size: bytes.next_power_of_two(),
|
||||
usage: buffer.usage(),
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
let total_quads = quads[0] + quads[1];
|
||||
if total_quads > 0 {
|
||||
let vertices: Vec<Vertex> = [batch.quads, batch.quads_through]
|
||||
.into_iter()
|
||||
.flat_map(|q| &q[..q.len() / 4 * 4])
|
||||
.copied()
|
||||
.collect();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
|
||||
grow(&mut self.quads, bytes.len() as u64);
|
||||
upload(&self.quads, bytes);
|
||||
if total_quads > self.index_quads {
|
||||
self.index_quads = total_quads.next_power_of_two();
|
||||
let indices: Vec<u32> = (0..self.index_quads)
|
||||
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q * 4 + i))
|
||||
.collect();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&indices);
|
||||
grow(&mut self.indices, bytes.len() as u64);
|
||||
upload(&self.indices, bytes);
|
||||
}
|
||||
}
|
||||
if lines[0] + lines[1] > 0 {
|
||||
let all: Vec<[f32; 8]> = batch
|
||||
.lines
|
||||
.iter()
|
||||
.chain(batch.lines_through)
|
||||
.copied()
|
||||
.collect();
|
||||
let bytes: &[u8] = bytemuck::cast_slice(&all);
|
||||
grow(&mut self.lines, bytes.len() as u64);
|
||||
upload(&self.lines, bytes);
|
||||
}
|
||||
let globals = Globals {
|
||||
view_proj: view_proj.to_cols_array_2d(),
|
||||
viewport: [viewport[0] as f32, viewport[1] as f32, 0.0, 0.0],
|
||||
};
|
||||
upload(&self.globals, bytemuck::bytes_of(&globals));
|
||||
self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("World primitives"),
|
||||
layout: &self.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: self.globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(atlas),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||
},
|
||||
],
|
||||
}));
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws what [`Self::prepare`] uploaded into a pass over the world's colour and depth:
|
||||
/// the depth-tested quads and lines, then those seen through walls over everything.
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
|
||||
let Some(group) = &self.group else {
|
||||
return;
|
||||
};
|
||||
let [quads, quads_through, lines, lines_through] = self.counts;
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
for (through, (quad_range, line_range)) in [
|
||||
(0..quads, 0..lines),
|
||||
(quads..quads + quads_through, lines..lines + lines_through),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
if !quad_range.is_empty() {
|
||||
pass.set_pipeline(&self.quad_pipelines[through]);
|
||||
pass.set_vertex_buffer(0, self.quads.slice(..));
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(quad_range.start * 6..quad_range.end * 6, 0, 0..1);
|
||||
}
|
||||
if !line_range.is_empty() {
|
||||
pass.set_pipeline(&self.line_pipelines[through]);
|
||||
pass.set_vertex_buffer(0, self.lines.slice(..));
|
||||
pass.draw(0..4, line_range);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use glam::Vec3;
|
||||
|
||||
const SIZE: u32 = 64;
|
||||
/// RGBA, as `Vertex::color` holds it.
|
||||
const RED: u32 = 0xFF00_00FF;
|
||||
const GREEN: u32 = 0xFF00_FF00;
|
||||
const BLUE: u32 = 0xFFFF_0000;
|
||||
/// ARGB, as a line carries it.
|
||||
const MAGENTA_ARGB: u32 = 0xFFFF_00FF;
|
||||
|
||||
/// A camera-relative quad of plain colour facing the camera, `z` blocks ahead of it, over
|
||||
/// the pixels `x0..x1`, `y0..y1` of the frame (y down).
|
||||
fn quad(view_proj: Mat4, [x0, y0, x1, y1]: [f32; 4], z: f32, rgba: u32) -> [Vertex; 4] {
|
||||
let depth = view_proj.project_point3(Vec3::new(0.0, 0.0, -z)).z;
|
||||
let inverse = view_proj.inverse();
|
||||
let at = |x: f32, y: f32| {
|
||||
let ndc = Vec3::new(
|
||||
x / SIZE as f32 * 2.0 - 1.0,
|
||||
1.0 - y / SIZE as f32 * 2.0,
|
||||
depth,
|
||||
);
|
||||
inverse.project_point3(ndc).to_array()
|
||||
};
|
||||
[at(x0, y0), at(x0, y1), at(x1, y1), at(x1, y0)].map(|pos| Vertex {
|
||||
pos,
|
||||
uv: [0.5, 0.5],
|
||||
layer: 0,
|
||||
color: rgba,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0, 0.0],
|
||||
})
|
||||
}
|
||||
|
||||
/// A horizontal line, 4 pixels wide, over the pixels `x0..x1` of row `y`, `z` blocks ahead.
|
||||
fn line(view_proj: Mat4, [x0, x1]: [f32; 2], y: f32, z: f32, argb: u32) -> [f32; 8] {
|
||||
let q = quad(view_proj, [x0, y, x1, y], z, 0);
|
||||
let [a, b] = [q[0].pos, q[3].pos];
|
||||
[
|
||||
a[0],
|
||||
a[1],
|
||||
a[2],
|
||||
b[0],
|
||||
b[1],
|
||||
b[2],
|
||||
f32::from_bits(argb),
|
||||
4.0,
|
||||
]
|
||||
}
|
||||
|
||||
/// A wall 5 blocks ahead fills the depth (reversed: nearer is greater). Behind it, a
|
||||
/// depth-tested quad and line stay hidden while the same seen through walls show; a
|
||||
/// depth-tested quad in front of the wall shows.
|
||||
#[test]
|
||||
fn depth_tested_primitives_hide_behind_walls_and_see_through_ones_do_not() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Some((device, queue)) =
|
||||
pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.ok()
|
||||
.and_then(|adapter| {
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
})
|
||||
else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let view_proj = super::super::world_projection(70.0, 1.0, 512.0);
|
||||
let wall = view_proj.project_point3(Vec3::new(0.0, 0.0, -5.0)).z;
|
||||
// Reversed depth: 10 blocks away is less than the wall, 2 blocks away more.
|
||||
assert!(view_proj.project_point3(Vec3::new(0.0, 0.0, -10.0)).z < wall);
|
||||
assert!(view_proj.project_point3(Vec3::new(0.0, 0.0, -2.0)).z > wall);
|
||||
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let texture = |size: u32, format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = texture(
|
||||
SIZE,
|
||||
format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = texture(
|
||||
SIZE,
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
// The atlas: one white texel, what plain colours sample.
|
||||
let atlas = texture(
|
||||
1,
|
||||
format,
|
||||
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
);
|
||||
queue.write_texture(
|
||||
atlas.as_image_copy(),
|
||||
&[255; 4],
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
atlas.size(),
|
||||
);
|
||||
|
||||
let (half, full) = (SIZE as f32 / 2.0, SIZE as f32);
|
||||
// Top left: behind the wall, depth tested. Top right: behind it, through walls.
|
||||
// Bottom left: in front of it, depth tested. Bottom right: lines behind it, the
|
||||
// depth-tested one along row 40, the one through walls along row 56.
|
||||
let quads = [
|
||||
quad(view_proj, [0.0, 0.0, half, half], 10.0, RED),
|
||||
quad(view_proj, [0.0, half, half, full], 2.0, BLUE),
|
||||
]
|
||||
.concat();
|
||||
let quads_through = quad(view_proj, [half, 0.0, full, half], 10.0, GREEN);
|
||||
let lines = [line(view_proj, [half, full], 40.0, 10.0, MAGENTA_ARGB)];
|
||||
let lines_through = [line(view_proj, [half, full], 56.0, 10.0, MAGENTA_ARGB)];
|
||||
let batch = Batch {
|
||||
quads: &quads,
|
||||
quads_through: &quads_through,
|
||||
lines: &lines,
|
||||
lines_through: &lines_through,
|
||||
};
|
||||
|
||||
let mut primitives = WorldPrimitives::new(&device, format);
|
||||
let atlas_view = atlas.create_view(&Default::default());
|
||||
assert!(primitives.prepare(
|
||||
&device,
|
||||
&mut |buffer, bytes| queue.write_buffer(buffer, 0, bytes),
|
||||
&batch,
|
||||
view_proj,
|
||||
[SIZE, SIZE],
|
||||
&atlas_view,
|
||||
));
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let color_view = color.create_view(&Default::default());
|
||||
let depth_view = depth.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wall),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
primitives.draw(&mut pass);
|
||||
}
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(SIZE * SIZE * 4),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(SIZE * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
color.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let texels = readback.slice(..).get_mapped_range().unwrap().to_vec();
|
||||
let at = |x: u32, y: u32| {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
[texels[i], texels[i + 1], texels[i + 2], texels[i + 3]]
|
||||
};
|
||||
let black = [0, 0, 0, 255];
|
||||
assert_eq!(
|
||||
at(16, 16),
|
||||
black,
|
||||
"a depth-tested quad behind the wall is hidden"
|
||||
);
|
||||
assert_eq!(
|
||||
at(48, 16),
|
||||
[0, 255, 0, 255],
|
||||
"a quad through walls shows behind it"
|
||||
);
|
||||
assert_eq!(
|
||||
at(16, 48),
|
||||
[0, 0, 255, 255],
|
||||
"a depth-tested quad before it shows"
|
||||
);
|
||||
assert_eq!(
|
||||
at(48, 40),
|
||||
black,
|
||||
"a depth-tested line behind the wall is hidden"
|
||||
);
|
||||
assert_eq!(
|
||||
at(48, 56),
|
||||
[255, 0, 255, 255],
|
||||
"a line through walls shows behind it"
|
||||
);
|
||||
// The line is its width (4 pixels) thick, not more.
|
||||
assert_eq!(at(48, 52), black);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Native mods' world primitives (gfx/world_primitives.rs): quads from the shared atlas and lines
|
||||
// with a width in pixels, camera-relative, at the end of the world pass.
|
||||
|
||||
struct Globals {
|
||||
// Camera-relative world to clip space, with TAA's jitter (what the world was drawn with).
|
||||
view_proj: mat4x4<f32>,
|
||||
// The frame's size in pixels (line widths are in its pixels).
|
||||
viewport: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@group(0) @binding(2) var atlas_sampler: sampler;
|
||||
|
||||
struct QuadOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
// A beam's sprite v range (start, length), else 0.
|
||||
@location(2) @interpolate(flat) wrap: vec2<f32>,
|
||||
}
|
||||
|
||||
// `Vertex` (mesher.rs): position, atlas UV, layer, RGBA colour, light.
|
||||
@vertex fn vs_quad(@location(0) pos: vec3<f32>, @location(1) uv: vec2<f32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: u32) -> QuadOut {
|
||||
var out: QuadOut;
|
||||
out.clip = globals.view_proj * vec4(pos, 1.0);
|
||||
out.uv = uv;
|
||||
out.color = color;
|
||||
// Layer 6 is a beacon beam's: its v repeats inside the sprite, whose range rides in the
|
||||
// light bytes (block_entity_render::beam).
|
||||
if (layer & 0xffffu) == 6u {
|
||||
out.wrap = vec2(f32(light & 0xffffu), f32(light >> 16u)) / 65535.0;
|
||||
} else {
|
||||
out.wrap = vec2(0.0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment fn fs_quad(in: QuadOut) -> @location(0) vec4<f32> {
|
||||
var uv = in.uv;
|
||||
if in.wrap.y > 0.0 { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
|
||||
let color = textureSample(atlas, atlas_sampler, uv) * in.color;
|
||||
if color.a <= 0.0 { discard; }
|
||||
return color;
|
||||
}
|
||||
|
||||
struct LineOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) color: vec4<f32>,
|
||||
}
|
||||
|
||||
// One instance per line (gizmos::camera_relative): its ends, ARGB colour and width in pixels,
|
||||
// a strip of 4 vertices across it, as terrain.wgsl's `vs_debug_line`.
|
||||
@vertex fn vs_line(@builtin(vertex_index) vertex: u32,
|
||||
@location(0) start: vec3<f32>, @location(1) end: vec3<f32>,
|
||||
@location(2) argb: vec4<f32>, @location(3) width: f32) -> LineOut {
|
||||
let position = select(start, end, vertex >= 2u);
|
||||
let view_shrink = 1.0 - 1.0 / 256.0;
|
||||
let line_start = globals.view_proj * vec4(position * view_shrink, 1.0);
|
||||
let line_end = globals.view_proj * vec4((position + (end - start)) * view_shrink, 1.0);
|
||||
let ndc1 = line_start.xyz / line_start.w;
|
||||
let ndc2 = line_end.xyz / line_end.w;
|
||||
let screen = globals.viewport.xy;
|
||||
let direction = normalize((ndc2.xy - ndc1.xy) * screen);
|
||||
var offset = vec2(-direction.y, direction.x) * width / screen;
|
||||
if offset.x < 0.0 { offset = -offset; }
|
||||
if vertex % 2u != 0u { offset = -offset; }
|
||||
var out: LineOut;
|
||||
out.clip = vec4((ndc1 + vec3(offset, 0.0)) * line_start.w, line_start.w);
|
||||
// ARGB in a little-endian u32 reads as B, G, R, A.
|
||||
out.color = argb.zyxw;
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment fn fs_line(in: LineOut) -> @location(0) vec4<f32> {
|
||||
return in.color;
|
||||
}
|
||||
@@ -598,15 +598,16 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
|
||||
let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p);
|
||||
let file = atlas_sources::texture_path(root, texture, "textures");
|
||||
decode(&file).ok().map(|image| {
|
||||
// Animated strips keep their first frame and animate,
|
||||
// like the packed files.
|
||||
if path.starts_with("block/") || path.starts_with("item/") {
|
||||
let mcmeta = horizon_core::prepared_files::read_to_string(
|
||||
&file.with_extension("png.mcmeta"),
|
||||
)
|
||||
.ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
animated = animation.is_some();
|
||||
// Animated strips keep their first frame and animate, like the
|
||||
// packed files, under any folder: vanilla animates every sprite of
|
||||
// these atlases (a server pack's `wynn/...` as well as `item/...`).
|
||||
let mcmeta = horizon_core::prepared_files::read_to_string(
|
||||
&file.with_extension("png.mcmeta"),
|
||||
)
|
||||
.ok();
|
||||
let animation = animation_of(mcmeta.as_deref());
|
||||
animated = animation.is_some();
|
||||
if animated || path.starts_with("block/") || path.starts_with("item/") {
|
||||
first_frame(image, animation.as_ref())
|
||||
} else {
|
||||
image
|
||||
@@ -1115,7 +1116,7 @@ impl HudAtlas {
|
||||
}
|
||||
files.retain(|p| {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
!s.contains("title/background") && !s.contains("presets")
|
||||
});
|
||||
// OptiFine random entity variants, under the names their scan gives them (those
|
||||
// outside `textures/` are `<ns>:optifine/random/...`).
|
||||
@@ -1515,6 +1516,8 @@ pub struct HudBatch {
|
||||
pub end_portal: EndPortalFill,
|
||||
/// `options.glintStrength` for the glint pass (`GlintAlpha`).
|
||||
pub glint_alpha: f32,
|
||||
/// PvP & HUD's glint colour (`pvp::Tweaks::glint_texel`), 0 for vanilla's.
|
||||
pub glint_colour: u32,
|
||||
/// A resource pack text shader's `GameTime`: the day's fraction
|
||||
/// (`gameTime % 24000` and the partial tick, over 24000).
|
||||
pub game_time: f32,
|
||||
@@ -1575,6 +1578,7 @@ impl HudBatch {
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
glint_colour: 0,
|
||||
game_time: 0.0,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
@@ -2255,13 +2259,19 @@ struct HudUniform {
|
||||
end_portal: [[f32; 4]; 3],
|
||||
}
|
||||
impl HudUniform {
|
||||
fn new(size: [f32; 4], fill: EndPortalFill) -> Self {
|
||||
fn new(size: [f32; 4], fill: EndPortalFill, glint_colour: u32) -> Self {
|
||||
let [r, g, b, _] = glint_colour.to_le_bytes().map(|c| f32::from(c) / 255.0);
|
||||
let tint = if glint_colour == 0 {
|
||||
[-1.0, 0.0, 0.0]
|
||||
} else {
|
||||
[r, g, b]
|
||||
};
|
||||
Self {
|
||||
size,
|
||||
end_portal: [
|
||||
fill.sprites[0],
|
||||
fill.sprites[1],
|
||||
[fill.game_time, 0.0, 0.0, 0.0],
|
||||
[fill.game_time, tint[0], tint[1], tint[2]],
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -2318,7 +2328,7 @@ impl HudRenderer {
|
||||
});
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("HUD size"),
|
||||
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
|
||||
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default(), 0)),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -2691,6 +2701,7 @@ impl HudRenderer {
|
||||
batch.glint_alpha,
|
||||
],
|
||||
batch.end_portal,
|
||||
batch.glint_colour,
|
||||
)),
|
||||
);
|
||||
if let Some(pack_text) = &self.pack_text {
|
||||
@@ -3356,6 +3367,45 @@ mod tests {
|
||||
.expect("the namespaced sprite animates");
|
||||
assert_eq!(animation.frame_count(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn an_animated_texture_outside_item_folders_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-dir-anim-{}", std::process::id()));
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
let custom = root.join("assets/minecraft/textures/custom");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::create_dir_all(&custom).unwrap();
|
||||
fs::write(
|
||||
atlases.join("items.json"),
|
||||
r#"{"sources": [{"type": "directory", "source": "custom", "prefix": "custom/"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A server pack's item under its own folder: two 16x16 frames, blue then yellow, and
|
||||
// a still 16x32 texture beside it.
|
||||
write_strip(
|
||||
&custom.join("gem.png"),
|
||||
16,
|
||||
32,
|
||||
&[[0, 0, 255, 255], [255, 255, 0, 255]],
|
||||
Some(r#"{"animation": {"frametime": 20}}"#),
|
||||
);
|
||||
write_strip(&custom.join("banner.png"), 16, 32, &[[9, 9, 9, 255]], None);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let rect = atlas.sprites["custom/gem"];
|
||||
// One frame, not the whole strip squeezed into a slot.
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 0, 255, 255]);
|
||||
let (_, animation) = atlas
|
||||
.sprite_animations
|
||||
.iter()
|
||||
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
|
||||
.expect("the sprite animates");
|
||||
assert_eq!(animation.frame_count(), 2);
|
||||
// A still texture there stays whole, as vanilla stitches it.
|
||||
let banner = atlas.sprites["custom/banner"];
|
||||
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
|
||||
}
|
||||
/// A resource root with one plain item texture and these CIT files (relative path, contents).
|
||||
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
let root =
|
||||
@@ -4131,6 +4181,7 @@ texture=wide",
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
glint_colour: 0,
|
||||
game_time: 0.0,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
struct Hud {
|
||||
size: vec4<f32>,
|
||||
// Blend::EndPortal: atlas rects (u0, v0, width, height) of end_sky.png and
|
||||
// end_portal.png, then vanilla's GameTime in x.
|
||||
// end_portal.png, then vanilla's GameTime in x and PvP & HUD's glint colour in yzw
|
||||
// (y below 0: vanilla's).
|
||||
end_portal: array<vec4<f32>, 3>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> hud: Hud;
|
||||
@@ -35,7 +36,11 @@ struct Out {
|
||||
if in.uv.x >= 2.0 {
|
||||
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a;
|
||||
if mask < 0.1 { discard; }
|
||||
let glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
|
||||
var glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
|
||||
let tint = hud.end_portal[2].yzw;
|
||||
if tint.x >= 0.0 {
|
||||
glint = vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
|
||||
}
|
||||
return vec4(glint.rgb * glint.rgb, mask);
|
||||
}
|
||||
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
|
||||
|
||||
@@ -84,6 +84,7 @@ pub mod potent_sulfur;
|
||||
pub mod preedit;
|
||||
pub mod providers;
|
||||
pub mod pvp;
|
||||
pub mod pvp_hud;
|
||||
pub mod screen_draw;
|
||||
pub mod screen_effects;
|
||||
pub mod sculk;
|
||||
|
||||
@@ -202,6 +202,17 @@ const DETAIL_LEVEL: u8 = 2;
|
||||
/// The most chunks one detail request asks for, one request each 250 ms:
|
||||
/// generated ones arrive at the decoration threads' pace, cached ones at once.
|
||||
const DETAIL_REQUEST: usize = 512;
|
||||
/// How long one frame spends applying far detail and visited summaries
|
||||
/// (`Lod::receive`).
|
||||
///
|
||||
/// Cached chunks answer a `DETAIL_REQUEST` all at once, and applying one
|
||||
/// copies the columns of every tile over it (`Arc::make_mut` on a snapshot a
|
||||
/// mesh job shares) and reduces the chunk to that tile's cell width. A whole
|
||||
/// burst in one frame cost 273 ms of it on the render thread, flying over far
|
||||
/// terrain at `lodDistance` 256: 18 FPS average, 482 ms at the 99th
|
||||
/// percentile, where the median frame was 6 ms. What the budget leaves waits
|
||||
/// in `Lod::detail_backlog` for the frames after it.
|
||||
const DETAIL_BUDGET: std::time::Duration = std::time::Duration::from_millis(3);
|
||||
/// Top solid face and optional fluid face, in absolute block coordinates.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Surface {
|
||||
@@ -1036,21 +1047,40 @@ impl ReducedColumns {
|
||||
levels: std::array::from_fn(|_| std::sync::OnceLock::new()),
|
||||
}
|
||||
}
|
||||
/// Builds the reduction levels whose bit is set in `levels`
|
||||
/// (`Shared::detail_widths`), off the render thread.
|
||||
fn warm(self, levels: u8) -> Self {
|
||||
for level in 0..self.levels.len() {
|
||||
if levels & (1 << level) != 0 {
|
||||
self.level(level);
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
/// The chunk reduced to cells `2 << level` blocks wide, built once.
|
||||
fn level(&self, level: usize) -> &[Column] {
|
||||
let width = 2 << level;
|
||||
self.levels[level].get_or_init(|| {
|
||||
// Timed on whichever thread first asks for this width, so the
|
||||
// frame profile tells a reduction apart from the column copying
|
||||
// around it (`Lod::apply_detail` does both).
|
||||
crate::spans::time("lod_reduce", || {
|
||||
let columns = &self.columns;
|
||||
(0..16 / width)
|
||||
.flat_map(|z| {
|
||||
(0..16 / width)
|
||||
.map(move |x| Column::reduced(columns, x * width, z * width, width))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
})
|
||||
}
|
||||
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
|
||||
if width == 1 {
|
||||
return &self.columns[z * 16 + x];
|
||||
}
|
||||
let level = width.trailing_zeros() as usize - 1;
|
||||
let reduced = self.levels[level].get_or_init(|| {
|
||||
let columns = &self.columns;
|
||||
(0..16 / width)
|
||||
.flat_map(|z| {
|
||||
(0..16 / width)
|
||||
.map(move |x| Column::reduced(columns, x * width, z * width, width))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
&reduced[(z / width) * (16 / width) + x / width]
|
||||
&self.level(level)[(z / width) * (16 / width) + x / width]
|
||||
}
|
||||
}
|
||||
enum Job {
|
||||
@@ -1065,6 +1095,14 @@ enum Job {
|
||||
refreshes: u64,
|
||||
},
|
||||
}
|
||||
/// A chunk's columns on their way to the tiles over it: what `Lod::receive`
|
||||
/// defers when a frame's [`DETAIL_BUDGET`] is spent.
|
||||
enum Summary {
|
||||
/// A chunk's detail, and the `refreshes` of the request it answers.
|
||||
Detail((i32, i32), ReducedColumns, u64),
|
||||
/// A visited chunk's summary.
|
||||
Observed((i32, i32), ReducedColumns),
|
||||
}
|
||||
enum Done {
|
||||
/// A chunk's detail, and the `refreshes` of the request it answers.
|
||||
Detail((i32, i32), ReducedColumns, u64),
|
||||
@@ -1149,6 +1187,19 @@ struct Shared {
|
||||
stop: AtomicBool,
|
||||
/// Reduced far detail: samplers approximate wide cells.
|
||||
approximate: AtomicBool,
|
||||
/// The reduction levels `Lod::apply_detail` reads, one bit each (level
|
||||
/// `i` is a cell `2 << i` blocks wide), so the far terrain thread builds
|
||||
/// them before it sends a chunk's columns on.
|
||||
///
|
||||
/// Learned rather than derived from the plan: a chunk's ancestors are
|
||||
/// read only where they were sampled, which the plan alone does not say.
|
||||
/// `apply_detail` sets a bit the first time it reads that width, so the
|
||||
/// first chunk of a width still reduces on the render thread and every
|
||||
/// chunk after it finds the work done. Bits are never cleared: a width
|
||||
/// the view has stopped using costs the worker a reduction nothing
|
||||
/// reads, and holding all four is exactly the eager behaviour this
|
||||
/// replaces.
|
||||
detail_widths: std::sync::atomic::AtomicU8,
|
||||
/// On small machines, hold queued far meshing while near terrain loads.
|
||||
mesh_held: AtomicBool,
|
||||
/// The near terrain is loading: the far threads step behind it
|
||||
@@ -1183,6 +1234,11 @@ pub struct Lod {
|
||||
pending: FxHashSet<TileKey>,
|
||||
detail_masks: FxHashMap<TileKey, [u64; 4]>,
|
||||
detail_request_pending: bool,
|
||||
/// Detail and visited summaries past a frame's [`DETAIL_BUDGET`], applied
|
||||
/// on the frames after it. Bounded by what the worker sends: a detail
|
||||
/// request is one per 250 ms and only for chunks whose detail is missing,
|
||||
/// so this drains between bursts rather than growing with the view.
|
||||
detail_backlog: std::collections::VecDeque<Summary>,
|
||||
/// `refresh`es so far (`Job::RequestDetail`), and the areas of the last
|
||||
/// ones, each with its number.
|
||||
refreshes: u64,
|
||||
@@ -1290,6 +1346,7 @@ impl Lod {
|
||||
mesh_wake: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
approximate: AtomicBool::new(false),
|
||||
detail_widths: std::sync::atomic::AtomicU8::new(0),
|
||||
mesh_held: AtomicBool::new(false),
|
||||
near_loading: AtomicBool::new(false),
|
||||
});
|
||||
@@ -1468,7 +1525,12 @@ impl Lod {
|
||||
'work: loop {
|
||||
for (key, columns) in sampler.drain_details() {
|
||||
if tx
|
||||
.send(Done::Detail(key, ReducedColumns::new(columns), asked))
|
||||
.send(Done::Detail(
|
||||
key,
|
||||
ReducedColumns::new(columns)
|
||||
.warm(worker.detail_widths.load(Ordering::Relaxed)),
|
||||
asked,
|
||||
))
|
||||
.is_err()
|
||||
{
|
||||
break 'work;
|
||||
@@ -1514,7 +1576,11 @@ impl Lod {
|
||||
};
|
||||
let columns = summarize(&chunk, kinds);
|
||||
sampler.observed(key, &columns);
|
||||
Done::Observed(key, ReducedColumns::new(columns))
|
||||
Done::Observed(
|
||||
key,
|
||||
ReducedColumns::new(columns)
|
||||
.warm(worker.detail_widths.load(Ordering::Relaxed)),
|
||||
)
|
||||
}
|
||||
Job::RequestDetail {
|
||||
chunks,
|
||||
@@ -1570,6 +1636,7 @@ impl Lod {
|
||||
pending: FxHashSet::default(),
|
||||
detail_masks: FxHashMap::default(),
|
||||
detail_request_pending: false,
|
||||
detail_backlog: std::collections::VecDeque::new(),
|
||||
refreshes: 0,
|
||||
refreshed: VecDeque::new(),
|
||||
detail_request_at: std::time::Instant::now(),
|
||||
@@ -2098,6 +2165,12 @@ impl Lod {
|
||||
};
|
||||
let [ox, _, oz] = key.origin();
|
||||
let stride = key.size() / 16;
|
||||
// What the far terrain thread should have ready next time.
|
||||
if stride > 1 {
|
||||
self.shared
|
||||
.detail_widths
|
||||
.fetch_or(1 << (stride.trailing_zeros() - 1), Ordering::Relaxed);
|
||||
}
|
||||
let x0 = ((cx * 16 - ox + stride - 1).div_euclid(stride)).clamp(0, 16);
|
||||
let x1 = (((cx + 1) * 16 - ox + stride - 1).div_euclid(stride)).clamp(0, 16);
|
||||
let z0 = ((cz * 16 - oz + stride - 1).div_euclid(stride)).clamp(0, 16);
|
||||
@@ -2269,6 +2342,12 @@ impl Lod {
|
||||
fn request_detail(&mut self, camera: [i32; 2]) {
|
||||
if self.held()
|
||||
|| self.detail_request_pending
|
||||
// Nothing new while summaries wait: a chunk whose detail is still
|
||||
// in the backlog has its mask clear, so it would be asked for
|
||||
// again, and answered from the cache at once. Without this the
|
||||
// backlog grew by a request every 250 ms and never drained --
|
||||
// 0.5 FPS, with `update` averaging 9.7 s a frame.
|
||||
|| !self.detail_backlog.is_empty()
|
||||
|| self.detail_request_at.elapsed() < std::time::Duration::from_millis(250)
|
||||
{
|
||||
return;
|
||||
@@ -2495,17 +2574,25 @@ impl Lod {
|
||||
self.wanted_stale = true;
|
||||
}
|
||||
fn receive(&mut self, meshes: &mut Vec<(TileKey, SectionMesh)>) {
|
||||
let started = std::time::Instant::now();
|
||||
// What an earlier frame left, before anything new: a chunk's detail
|
||||
// must not overtake the detail of a chunk that arrived before it.
|
||||
while started.elapsed() < DETAIL_BUDGET {
|
||||
let Some(summary) = self.detail_backlog.pop_front() else {
|
||||
break;
|
||||
};
|
||||
self.apply_summary(summary);
|
||||
}
|
||||
while let Ok(done) = self.results.try_recv() {
|
||||
match done {
|
||||
// Read before a `refresh` over it since: asked again (its
|
||||
// bits stay clear), never kept over the newer data.
|
||||
Done::Detail(key, _, asked) if self.refreshed_since(key, asked) => {}
|
||||
Done::Detail(key, columns, _) => {
|
||||
crate::spans::time("lod_detail", || self.finish_detail(key, columns))
|
||||
}
|
||||
Done::Observed(key, columns) => {
|
||||
crate::spans::time("lod_detail", || self.finish_observed(key, columns))
|
||||
}
|
||||
Done::Detail(key, columns, asked) => self.take_summary(
|
||||
Summary::Detail(key, columns, asked),
|
||||
started.elapsed() < DETAIL_BUDGET,
|
||||
),
|
||||
Done::Observed(key, columns) => self.take_summary(
|
||||
Summary::Observed(key, columns),
|
||||
started.elapsed() < DETAIL_BUDGET,
|
||||
),
|
||||
Done::DetailRequested => self.detail_request_pending = false,
|
||||
Done::Sample {
|
||||
key,
|
||||
@@ -2530,6 +2617,28 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Applies a summary while the frame has budget and nothing is already
|
||||
/// waiting, else leaves it for a later frame.
|
||||
fn take_summary(&mut self, summary: Summary, within_budget: bool) {
|
||||
if within_budget && self.detail_backlog.is_empty() {
|
||||
self.apply_summary(summary);
|
||||
} else {
|
||||
self.detail_backlog.push_back(summary);
|
||||
}
|
||||
}
|
||||
/// A chunk's columns applied to the tiles over it.
|
||||
///
|
||||
/// `refreshed_since` is tested here and not when the result arrives, so a
|
||||
/// refresh over the chunk while the summary waited still drops it: it was
|
||||
/// read before that refresh, and the tiles ask again with their bits
|
||||
/// clear.
|
||||
fn apply_summary(&mut self, summary: Summary) {
|
||||
crate::spans::time("lod_detail", || match summary {
|
||||
Summary::Detail(key, _, asked) if self.refreshed_since(key, asked) => {}
|
||||
Summary::Detail(key, columns, _) => self.finish_detail(key, columns),
|
||||
Summary::Observed(key, columns) => self.finish_observed(key, columns),
|
||||
});
|
||||
}
|
||||
/// A finished mesh: shown when it is newer than what the tile shows now.
|
||||
/// A result newer than the frame's current one is still useful while a
|
||||
/// newer job is pending — re-marking a tile re-dirties its neighbours and
|
||||
@@ -3640,6 +3749,144 @@ mod tests {
|
||||
reduced.get(0, 0, 16);
|
||||
assert_eq!(built(&reduced), [4, 16]);
|
||||
}
|
||||
/// A summary past a frame's budget waits for a later frame rather than
|
||||
/// being dropped, and no new detail is asked for while any waits.
|
||||
///
|
||||
/// Without the second part the backlog grew by a `DETAIL_REQUEST` every
|
||||
/// 250 ms and never drained: a chunk whose summary is still waiting has
|
||||
/// its detail mask clear, so it was asked for again and the cache
|
||||
/// answered at once. That measured 0.5 FPS, `update` averaging 9.7 s.
|
||||
#[test]
|
||||
fn a_summary_past_the_budget_waits_and_holds_back_the_next_request() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
let chunk = (3, 5);
|
||||
let columns = || ReducedColumns::new(Arc::new(std::array::from_fn(|_| Column::default())));
|
||||
|
||||
// Within budget and with nothing waiting, a summary applies at once.
|
||||
lod.take_summary(Summary::Observed(chunk, columns()), true);
|
||||
assert!(lod.detail_backlog.is_empty(), "applied, not deferred");
|
||||
assert!(lod.saved.contains_key(&chunk), "and it reached `saved`");
|
||||
|
||||
// Past the budget it waits, and anything after it waits behind it so
|
||||
// a chunk's detail cannot overtake a chunk that arrived earlier.
|
||||
lod.take_summary(Summary::Observed((9, 9), columns()), false);
|
||||
lod.take_summary(Summary::Observed((10, 9), columns()), true);
|
||||
assert_eq!(
|
||||
lod.detail_backlog.len(),
|
||||
2,
|
||||
"the second waits behind the first"
|
||||
);
|
||||
assert!(!lod.saved.contains_key(&(9, 9)));
|
||||
|
||||
// Nothing new is asked for while summaries wait, whatever the
|
||||
// request interval allows. Asserted on `detail_request_at`, which
|
||||
// `request_detail` records once it is past its guards: a test
|
||||
// scheduler has no active tile wanting detail, so `pending` would
|
||||
// stay false either way and prove nothing.
|
||||
let long_ago = std::time::Instant::now() - std::time::Duration::from_secs(10);
|
||||
lod.detail_request_at = long_ago;
|
||||
lod.detail_request_pending = false;
|
||||
lod.request_detail([0, 0]);
|
||||
assert_eq!(lod.detail_request_at, long_ago, "held back by the backlog");
|
||||
|
||||
// A later frame applies what waited, in the order it arrived.
|
||||
while let Some(summary) = lod.detail_backlog.pop_front() {
|
||||
lod.apply_summary(summary);
|
||||
}
|
||||
assert!(lod.saved.contains_key(&(9, 9)), "nothing was dropped");
|
||||
assert!(lod.saved.contains_key(&(10, 9)));
|
||||
|
||||
// Drained, requests resume.
|
||||
lod.detail_request_at = long_ago;
|
||||
lod.request_detail([0, 0]);
|
||||
assert!(
|
||||
lod.detail_request_at > long_ago,
|
||||
"asked once the backlog drained"
|
||||
);
|
||||
}
|
||||
/// `warm` builds exactly the levels asked for, off the render thread, and
|
||||
/// `get` then finds them built.
|
||||
#[test]
|
||||
fn warming_builds_the_levels_asked_for_and_no_others() {
|
||||
let built = |reduced: &ReducedColumns| {
|
||||
reduced
|
||||
.levels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, level)| level.get().is_some())
|
||||
.map(|(level, _)| 2usize << level)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let chunk = || {
|
||||
Arc::new(std::array::from_fn::<Column, 256, _>(|i| Column {
|
||||
surface: None,
|
||||
spans: vec![Span {
|
||||
min: 0,
|
||||
max: 4 + (i % 3) as i16,
|
||||
state: BlockStateId(1),
|
||||
biome: 0,
|
||||
light: 240,
|
||||
}]
|
||||
.into(),
|
||||
}))
|
||||
};
|
||||
// No bits: nothing is built, as before.
|
||||
assert!(built(&ReducedColumns::new(chunk()).warm(0)).is_empty());
|
||||
// Widths 2 and 8 are levels 0 and 2.
|
||||
let warmed = ReducedColumns::new(chunk()).warm(0b101);
|
||||
assert_eq!(built(&warmed), [2, 8]);
|
||||
// Reading a warmed width builds nothing more; an unwarmed one does.
|
||||
warmed.get(0, 0, 2);
|
||||
warmed.get(6, 6, 8);
|
||||
assert_eq!(built(&warmed), [2, 8], "already built");
|
||||
warmed.get(0, 0, 4);
|
||||
assert_eq!(built(&warmed), [2, 4, 8]);
|
||||
// Warming is the same reduction as reading would have made.
|
||||
let lazy = ReducedColumns::new(chunk());
|
||||
for (x, z, width) in [(0, 0, 2), (6, 6, 8), (4, 8, 4), (0, 0, 16)] {
|
||||
assert_eq!(
|
||||
warmed.get(x, z, width),
|
||||
lazy.get(x, z, width),
|
||||
"width {width} at {x},{z}"
|
||||
);
|
||||
}
|
||||
}
|
||||
/// Applying a chunk's detail records the cell widths it read, so the far
|
||||
/// terrain thread reduces them before it sends the next chunk.
|
||||
#[test]
|
||||
fn applying_detail_records_the_widths_it_read() {
|
||||
let mut lod = Lod::scheduler_for_test();
|
||||
assert_eq!(lod.shared.detail_widths.load(Ordering::Relaxed), 0);
|
||||
let chunk = (0, 0);
|
||||
let columns = || ReducedColumns::new(Arc::new(std::array::from_fn(|_| Column::default())));
|
||||
// Only tiles in `ready` are read. A level-0 tile has cells one block
|
||||
// wide, which is the chunk's own columns: no reduction, no bit.
|
||||
lod.ready.insert(
|
||||
TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 0,
|
||||
},
|
||||
Arc::new(vec![Column::default(); CELLS * CELLS]),
|
||||
);
|
||||
lod.apply_detail(chunk, &columns());
|
||||
assert_eq!(
|
||||
lod.shared.detail_widths.load(Ordering::Relaxed),
|
||||
0,
|
||||
"full-detail cells read the chunk itself"
|
||||
);
|
||||
// A level-2 tile has cells 4 blocks wide: reduction level 1.
|
||||
lod.ready.insert(
|
||||
TileKey {
|
||||
x: 0,
|
||||
z: 0,
|
||||
level: 2,
|
||||
},
|
||||
Arc::new(vec![Column::default(); CELLS * CELLS]),
|
||||
);
|
||||
lod.apply_detail(chunk, &columns());
|
||||
assert_eq!(lod.shared.detail_widths.load(Ordering::Relaxed), 0b10);
|
||||
}
|
||||
/// Detail read before a refresh over its chunk is dropped (asked
|
||||
/// again); elsewhere, or read after, it is kept.
|
||||
#[test]
|
||||
|
||||
@@ -131,6 +131,16 @@ impl ModList {
|
||||
/// Turns installed mod `i` on or off (a dev mod stays on). A native mod's
|
||||
/// line says `false` when off, so that the launcher's next build leaves
|
||||
/// it out, and `true` when on again.
|
||||
/// Turn the installed mod `id` on (nothing when it isn't installed).
|
||||
pub fn turn_on(&mut self, id: &str) {
|
||||
let Some(m) = self.installed.iter().find(|m| m.id == id && !m.dev) else {
|
||||
return;
|
||||
};
|
||||
if m.native.is_some() {
|
||||
self.disabled.remove(&m.id);
|
||||
}
|
||||
self.enabled.insert(m.id.clone());
|
||||
}
|
||||
pub fn toggle(&mut self, i: usize) {
|
||||
let Some(m) = self.installed.get(i).filter(|m| !m.dev) else {
|
||||
return;
|
||||
|
||||
@@ -1309,6 +1309,8 @@ enum Act {
|
||||
ResetPadKey(&'static str),
|
||||
ResetPadKeys,
|
||||
ToggleMod(usize),
|
||||
/// PvP & HUD's PvP preset (`pvp::apply_preset`).
|
||||
PvpPreset,
|
||||
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
|
||||
PickShaderPack(usize),
|
||||
OpenShaderPackFolder,
|
||||
@@ -1566,6 +1568,9 @@ pub struct OptionsUi {
|
||||
pub selected_pad_action: Option<&'static str>,
|
||||
/// The glyphs the binds list shows (the setting, else the pad's family).
|
||||
pub pad_style: GlyphStyle,
|
||||
/// The pad goes by its letters (`PadSettings::nintendo_letters`) and the glyphs are
|
||||
/// Nintendo's: each part shows the letter of the button playing it.
|
||||
pub pad_by_letter: bool,
|
||||
pub languages: Vec<LanguageInfo>,
|
||||
language_choice: Option<String>,
|
||||
language_search: ui::EditBox,
|
||||
@@ -1647,6 +1652,7 @@ impl Default for OptionsUi {
|
||||
selected_native_key: None,
|
||||
selected_pad_action: None,
|
||||
pad_style: GlyphStyle::Xbox,
|
||||
pad_by_letter: false,
|
||||
languages: Vec::new(),
|
||||
language_choice: None,
|
||||
language_search: search,
|
||||
@@ -1939,6 +1945,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
Act::Cycle(opt) => Some(opt.tooltip_key(s)),
|
||||
Act::Push(Page::Language) => Some("options.language.tooltip".into()),
|
||||
Act::Push(Page::Accessibility) => Some("options.accessibility.tooltip".into()),
|
||||
Act::PvpPreset => Some("horizon.pvp.preset.tooltip".into()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(key) = key.filter(|key| lang::has(key)) {
|
||||
@@ -2469,7 +2476,20 @@ impl OptionsUi {
|
||||
widget
|
||||
}
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
// The PvP preset first; on the Extras page beside the way to the PvP & HUD page.
|
||||
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
|
||||
let mut rows = vec![Row::Buttons(
|
||||
preset,
|
||||
(self.page() == Page::Extras).then(|| {
|
||||
button(
|
||||
0.0,
|
||||
0.0,
|
||||
150.0,
|
||||
tr("horizon.pages.open.pvp"),
|
||||
Act::Push(Page::Pvp),
|
||||
)
|
||||
}),
|
||||
)];
|
||||
let mut section = Vec::new();
|
||||
let flush = |rows: &mut Vec<Row>, section: &mut Vec<crate::extras::Item>| {
|
||||
rows.extend(
|
||||
@@ -3244,6 +3264,7 @@ impl OptionsUi {
|
||||
});
|
||||
let mut message = match pad.binding(action) {
|
||||
Some(b) => {
|
||||
let b = if self.pad_by_letter { b.by_letter() } else { b };
|
||||
let [r, g, bl] = b.color(self.pad_style);
|
||||
Text::literal(b.label(self.pad_style))
|
||||
.with_color(u32::from(r) << 16 | u32::from(g) << 8 | u32::from(bl))
|
||||
@@ -4299,6 +4320,10 @@ impl OptionsUi {
|
||||
}
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::PvpPreset => {
|
||||
crate::pvp::apply_preset(s);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::AccessibilityHelp => events.push(Event::AccessibilityHelp),
|
||||
Act::PickShaderPack(i) => {
|
||||
if let Some(pack) = s.shader_packs.get(i) {
|
||||
@@ -5240,6 +5265,10 @@ impl OptionsUi {
|
||||
}
|
||||
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
|
||||
Act::OpenModFolder => events.push(Event::OpenModFolder),
|
||||
Act::PvpPreset => {
|
||||
crate::pvp::apply_preset(s);
|
||||
events.push(Event::Changed);
|
||||
}
|
||||
Act::AccessibilityHelp => events.push(Event::AccessibilityHelp),
|
||||
Act::PickShaderPack(i) => {
|
||||
if let Some(pack) = s.shader_packs.get(i) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! PvP and HUD options (`docs/pvp.md`): a custom crosshair. Kept in
|
||||
//! PvP and HUD options (`docs/pvp.md`): a custom crosshair, block outline, hit colour and HUD
|
||||
//! widgets (`pvp_hud`). Kept in
|
||||
//! `horizon-extras.txt` with the other extras ([`crate::extras::Extras::pvp`]) and shown on
|
||||
//! Horizon Settings > PvP & HUD. Every option is off by default, so the game looks like
|
||||
//! vanilla until the player changes one, and none of them shows anything vanilla doesn't
|
||||
@@ -71,7 +72,7 @@ impl Colour {
|
||||
pub fn argb(self) -> u32 {
|
||||
match self {
|
||||
Colour::Inverted | Colour::White => 0xFFFF_FFFF,
|
||||
Colour::Red => 0xFFFF_3030,
|
||||
Colour::Red => 0xFFFF_0000,
|
||||
Colour::Orange => 0xFFFF_A020,
|
||||
Colour::Yellow => 0xFFFF_FF40,
|
||||
Colour::Green => 0xFF40_FF40,
|
||||
@@ -87,6 +88,18 @@ impl Colour {
|
||||
fn step(self, back: bool) -> Colour {
|
||||
step(&Self::ALL, self, back)
|
||||
}
|
||||
/// The next solid colour (`Inverted` left out).
|
||||
fn step_solid(self, back: bool) -> Colour {
|
||||
step(&Self::ALL[1..], self, back)
|
||||
}
|
||||
/// The colour at `percent` opacity, as ARGB.
|
||||
pub fn with_opacity(self, percent: u8) -> u32 {
|
||||
self.argb() & 0x00FF_FFFF | alpha(percent) << 24
|
||||
}
|
||||
}
|
||||
/// `percent` of 255, rounded.
|
||||
fn alpha(percent: u8) -> u32 {
|
||||
(u32::from(percent.min(100)) * 255 + 50) / 100
|
||||
}
|
||||
|
||||
/// The next (or previous) value of `all` after `v`, wrapping.
|
||||
@@ -97,11 +110,15 @@ fn step<T: Copy + PartialEq>(all: &[T], v: T, back: bool) -> T {
|
||||
}
|
||||
/// `v` one step up or down within `lo..=hi`, wrapping.
|
||||
fn step_num(v: u8, lo: u8, hi: u8, back: bool) -> u8 {
|
||||
step_by(v, (lo, hi), 1, back)
|
||||
}
|
||||
/// `v` `by` up or down within `lo..=hi`, wrapping.
|
||||
fn step_by(v: u8, (lo, hi): (u8, u8), by: u8, back: bool) -> u8 {
|
||||
match (back, v) {
|
||||
(false, v) if v >= hi => lo,
|
||||
(false, v) => v + 1,
|
||||
(false, v) => (v + by).min(hi),
|
||||
(true, v) if v <= lo => hi,
|
||||
(true, v) => v - 1,
|
||||
(true, v) => v.saturating_sub(by).max(lo),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +175,103 @@ impl Shape {
|
||||
}
|
||||
}
|
||||
|
||||
/// A time of day this player's view can stay at (`ViewTime::day_time` in vanilla's ticks).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ViewTime {
|
||||
Morning,
|
||||
Noon,
|
||||
Sunset,
|
||||
Midnight,
|
||||
}
|
||||
impl ViewTime {
|
||||
pub const ALL: [ViewTime; 4] = [
|
||||
ViewTime::Morning,
|
||||
ViewTime::Noon,
|
||||
ViewTime::Sunset,
|
||||
ViewTime::Midnight,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ViewTime::Morning => "morning",
|
||||
ViewTime::Noon => "noon",
|
||||
ViewTime::Sunset => "sunset",
|
||||
ViewTime::Midnight => "midnight",
|
||||
}
|
||||
}
|
||||
fn english(self) -> &'static str {
|
||||
match self {
|
||||
ViewTime::Morning => "Morning",
|
||||
ViewTime::Noon => "Noon",
|
||||
ViewTime::Sunset => "Sunset",
|
||||
ViewTime::Midnight => "Midnight",
|
||||
}
|
||||
}
|
||||
/// The time of day it shows, as `/time set` takes it.
|
||||
pub fn day_time(self) -> i64 {
|
||||
match self {
|
||||
ViewTime::Morning => 1000,
|
||||
ViewTime::Noon => 6000,
|
||||
ViewTime::Sunset => 12500,
|
||||
ViewTime::Midnight => 18000,
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> String {
|
||||
crate::lang::get_or(&format!("horizon.pvp.time.{}", self.name()), self.english())
|
||||
}
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|c| c.name() == s)
|
||||
}
|
||||
}
|
||||
/// The weather this player's view can show.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ViewWeather {
|
||||
Clear,
|
||||
Rain,
|
||||
Thunder,
|
||||
}
|
||||
impl ViewWeather {
|
||||
pub const ALL: [ViewWeather; 3] = [ViewWeather::Clear, ViewWeather::Rain, ViewWeather::Thunder];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ViewWeather::Clear => "clear",
|
||||
ViewWeather::Rain => "rain",
|
||||
ViewWeather::Thunder => "thunder",
|
||||
}
|
||||
}
|
||||
fn english(self) -> &'static str {
|
||||
match self {
|
||||
ViewWeather::Clear => "Clear",
|
||||
ViewWeather::Rain => "Rain",
|
||||
ViewWeather::Thunder => "Thunder",
|
||||
}
|
||||
}
|
||||
/// `Level.getRainLevel` and `getThunderLevel` as `/weather` leaves them.
|
||||
pub fn levels(self) -> (f32, f32) {
|
||||
match self {
|
||||
ViewWeather::Clear => (0.0, 0.0),
|
||||
ViewWeather::Rain => (1.0, 0.0),
|
||||
ViewWeather::Thunder => (1.0, 1.0),
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> String {
|
||||
crate::lang::get_or(
|
||||
&format!("horizon.pvp.weather.{}", self.name()),
|
||||
self.english(),
|
||||
)
|
||||
}
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|c| c.name() == s)
|
||||
}
|
||||
}
|
||||
/// The day time this player sees with `fixed` set: the same day (the moon's phase follows the
|
||||
/// world's), at that time.
|
||||
pub fn view_day_time(world: i64, fixed: Option<ViewTime>) -> i64 {
|
||||
match fixed {
|
||||
Some(t) => world.div_euclid(24000) * 24000 + t.day_time(),
|
||||
None => world,
|
||||
}
|
||||
}
|
||||
|
||||
/// The crosshair's look. Its defaults draw vanilla's: a 15-pixel cross, one pixel thick,
|
||||
/// inverting what is under it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -339,9 +453,243 @@ impl CrosshairCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// The block outline (`LevelRenderer.submitBlockOutline`). Off, vanilla's: black at 40%, or the
|
||||
/// accessibility option's high contrast one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Outline {
|
||||
pub custom: bool,
|
||||
/// A solid colour, at `opacity` percent.
|
||||
pub colour: Colour,
|
||||
pub opacity: u8,
|
||||
/// Times vanilla's width (`Window.getAppropriateLineWidth`).
|
||||
pub thickness: u8,
|
||||
/// The block's faces tinted with this colour, lightly.
|
||||
pub fill: Option<Colour>,
|
||||
}
|
||||
pub const OPACITIES: (u8, u8) = (10, 100);
|
||||
pub const OUTLINE_THICKNESSES: (u8, u8) = (1, 4);
|
||||
/// How much the fill covers the block's faces, in percent.
|
||||
pub const FILL_OPACITY: u8 = 25;
|
||||
impl Default for Outline {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom: false,
|
||||
colour: Colour::Black,
|
||||
opacity: 40,
|
||||
thickness: 1,
|
||||
fill: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Outline {
|
||||
/// The stroke's ARGB and its width as a multiple of vanilla's; none for vanilla's.
|
||||
pub fn stroke(&self) -> Option<(u32, f32)> {
|
||||
self.custom.then(|| {
|
||||
(
|
||||
self.colour.with_opacity(self.opacity),
|
||||
f32::from(self.thickness),
|
||||
)
|
||||
})
|
||||
}
|
||||
/// The fill's ARGB, when there is one.
|
||||
pub fn fill(&self) -> Option<u32> {
|
||||
self.fill
|
||||
.filter(|_| self.custom)
|
||||
.map(|c| c.with_opacity(FILL_OPACITY))
|
||||
}
|
||||
}
|
||||
/// The tint a hurt entity flashes (`OverlayTexture`'s red rows). Off, vanilla's: red over 30%.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Hit {
|
||||
pub custom: bool,
|
||||
pub colour: Colour,
|
||||
/// How much of the colour covers the entity, in percent.
|
||||
pub opacity: u8,
|
||||
}
|
||||
pub const HIT_OPACITIES: (u8, u8) = (0, 100);
|
||||
impl Default for Hit {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
custom: false,
|
||||
colour: Colour::Red,
|
||||
opacity: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Hit {
|
||||
/// The overlay texel as RGBA bytes in a little-endian u32 (WGSL's `unpack4x8unorm`), its alpha
|
||||
/// what of the entity's own colour stays (`entity.fsh` mixes by it); 0 for vanilla's texel.
|
||||
pub fn texel(&self) -> u32 {
|
||||
if !self.custom {
|
||||
return 0;
|
||||
}
|
||||
let [_, r, g, b] = self.colour.argb().to_be_bytes();
|
||||
u32::from_le_bytes([r, g, b, (255 - alpha(self.opacity)) as u8])
|
||||
}
|
||||
}
|
||||
|
||||
/// What the renderer reads of these options each frame (`FrameInputs::pvp`); the default is
|
||||
/// vanilla's look.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Frame {
|
||||
/// The block outline's stroke ([`Outline::stroke`]).
|
||||
pub outline: Option<(u32, f32)>,
|
||||
/// The hurt overlay texel ([`Hit::texel`]), 0 for vanilla's.
|
||||
pub hurt_texel: u32,
|
||||
/// The glint's colour ([`Tweaks::glint_texel`]), 0 for vanilla's.
|
||||
pub glint: u32,
|
||||
}
|
||||
impl Pvp {
|
||||
pub fn frame(&self) -> Frame {
|
||||
Frame {
|
||||
outline: self.outline.stroke(),
|
||||
hurt_texel: self.hit.texel(),
|
||||
glint: self.tweaks.glint_texel(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The HUD widgets (`pvp_hud`), each in a corner of its own.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Hud {
|
||||
/// The armour worn, head first, with each piece's durability.
|
||||
pub armour: bool,
|
||||
pub armour_corner: Corner,
|
||||
pub armour_percent: bool,
|
||||
/// The active effects: icon, name and level, time left. They replace vanilla's icons.
|
||||
pub effects: bool,
|
||||
pub effects_corner: Corner,
|
||||
/// How many of the held item the inventory holds (arrows with a bow or crossbow).
|
||||
pub held: bool,
|
||||
pub held_corner: Corner,
|
||||
/// The latency the server reports for this player (the tab list's bars), in multiplayer.
|
||||
pub ping: bool,
|
||||
pub ping_corner: Corner,
|
||||
/// The block position and the direction faced, as F3 shows them; hidden when F3 is
|
||||
/// reduced (`reducedDebugInfo`).
|
||||
pub coords: bool,
|
||||
pub coords_corner: Corner,
|
||||
/// The server's address, in multiplayer.
|
||||
pub server: bool,
|
||||
pub server_corner: Corner,
|
||||
/// Hits in a row the server confirmed, until this player is hurt or 3 seconds pass.
|
||||
pub combo: bool,
|
||||
pub combo_corner: Corner,
|
||||
/// A dark box behind each widget.
|
||||
pub background: bool,
|
||||
}
|
||||
impl Default for Hud {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
armour: false,
|
||||
armour_corner: Corner::BottomRight,
|
||||
armour_percent: false,
|
||||
effects: false,
|
||||
effects_corner: Corner::TopRight,
|
||||
held: false,
|
||||
held_corner: Corner::BottomRight,
|
||||
ping: false,
|
||||
ping_corner: Corner::TopLeft,
|
||||
coords: false,
|
||||
coords_corner: Corner::TopLeft,
|
||||
server: false,
|
||||
server_corner: Corner::TopLeft,
|
||||
combo: false,
|
||||
combo_corner: Corner::TopLeft,
|
||||
background: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use crate::extras::Corner;
|
||||
/// The value of the view options while they follow the world.
|
||||
fn world_label() -> String {
|
||||
crate::lang::get_or("horizon.pvp.view.world", "World's")
|
||||
}
|
||||
fn corner_label(c: Corner) -> String {
|
||||
crate::lang::get_or(&format!("horizon.pvp.corner.{}", c.name()), c.label())
|
||||
}
|
||||
|
||||
/// Smaller changes to what vanilla shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Tweaks {
|
||||
/// Enchantment levels as numbers in tooltips ("Sharpness 5").
|
||||
pub numeric_enchants: bool,
|
||||
/// How far down the first-person fire overlay moves, in percent of its height (0: vanilla).
|
||||
pub fire_lower: u8,
|
||||
/// The first-person shield's size, in percent (100: vanilla).
|
||||
pub shield_size: u8,
|
||||
/// The totem animation's size, in percent (100: vanilla).
|
||||
pub totem_size: u8,
|
||||
/// The enchantment glint's colour; `None` is vanilla's purple.
|
||||
pub glint: Option<Colour>,
|
||||
/// Water fog as after 30 seconds under water (vanilla's clearest), from the first moment.
|
||||
pub clear_water: bool,
|
||||
/// Lava fog as with Fire Resistance (vanilla's clearest), without the effect.
|
||||
pub clear_lava: bool,
|
||||
/// The time of day this player sees, `None` for the world's. Never sent to the server.
|
||||
pub time: Option<ViewTime>,
|
||||
/// The weather this player sees, `None` for the world's. Never sent to the server.
|
||||
pub weather: Option<ViewWeather>,
|
||||
}
|
||||
impl Default for Tweaks {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
numeric_enchants: false,
|
||||
fire_lower: 0,
|
||||
shield_size: 100,
|
||||
totem_size: 100,
|
||||
glint: None,
|
||||
clear_water: false,
|
||||
clear_lava: false,
|
||||
time: None,
|
||||
weather: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub const FIRE_LOWER: (u8, u8) = (0, 80);
|
||||
pub const SHIELD_SIZES: (u8, u8) = (50, 100);
|
||||
pub const TOTEM_SIZES: (u8, u8) = (25, 100);
|
||||
impl Tweaks {
|
||||
/// `colour` as the glint shaders read it: `0` for vanilla's, else `0xFF` alpha over RGB in
|
||||
/// little-endian bytes (`unpack4x8unorm`'s order).
|
||||
pub fn glint_texel(&self) -> u32 {
|
||||
self.glint.map_or(0, |c| {
|
||||
let [_, r, g, b] = c.argb().to_be_bytes();
|
||||
u32::from_le_bytes([r, g, b, 255])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Chat options.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ChatOptions {
|
||||
/// The local time before each message.
|
||||
pub timestamps: bool,
|
||||
/// A message repeated in a row shows once with its count.
|
||||
pub compact: bool,
|
||||
/// A click on a message (where it has no click action) copies it.
|
||||
pub copy: bool,
|
||||
/// Ctrl+F in the chat searches the chat's messages.
|
||||
pub search: bool,
|
||||
}
|
||||
impl ChatOptions {
|
||||
/// What `chat::Chat` lays its lines out with.
|
||||
pub fn qol(&self) -> crate::chat::ChatQol {
|
||||
crate::chat::ChatQol {
|
||||
timestamps: self.timestamps,
|
||||
compact: self.compact,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Pvp {
|
||||
pub crosshair: Crosshair,
|
||||
pub outline: Outline,
|
||||
pub hit: Hit,
|
||||
pub hud: Hud,
|
||||
pub tweaks: Tweaks,
|
||||
pub chat: ChatOptions,
|
||||
}
|
||||
fn flag(value: &str, d: bool) -> bool {
|
||||
match value {
|
||||
@@ -357,6 +705,8 @@ impl Pvp {
|
||||
/// One `key:value` line of `horizon-extras.txt`; false when the key isn't a PvP one.
|
||||
pub fn parse_line(&mut self, key: &str, value: &str) -> bool {
|
||||
let c = &mut self.crosshair;
|
||||
let (o, h, u) = (&mut self.outline, &mut self.hit, &mut self.hud);
|
||||
let t = &mut self.tweaks;
|
||||
match key {
|
||||
"crosshair" => c.custom = flag(value, c.custom),
|
||||
"crosshairShape" => c.shape = Shape::parse(value).unwrap_or(c.shape),
|
||||
@@ -372,6 +722,63 @@ impl Pvp {
|
||||
v => Colour::parse(v).or(c.hover),
|
||||
}
|
||||
}
|
||||
"outline" => o.custom = flag(value, o.custom),
|
||||
"outlineColour" => o.colour = Colour::parse(value).unwrap_or(o.colour),
|
||||
"outlineOpacity" => o.opacity = num(value, OPACITIES, o.opacity),
|
||||
"outlineThickness" => o.thickness = num(value, OUTLINE_THICKNESSES, o.thickness),
|
||||
"outlineFill" => {
|
||||
o.fill = match value {
|
||||
"off" => None,
|
||||
v => Colour::parse(v).or(o.fill),
|
||||
}
|
||||
}
|
||||
"hit" => h.custom = flag(value, h.custom),
|
||||
"hitColour" => h.colour = Colour::parse(value).unwrap_or(h.colour),
|
||||
"hitOpacity" => h.opacity = num(value, HIT_OPACITIES, h.opacity),
|
||||
"hudArmour" => u.armour = flag(value, u.armour),
|
||||
"hudArmourCorner" => u.armour_corner = Corner::parse(value, u.armour_corner),
|
||||
"hudArmourPercent" => u.armour_percent = flag(value, u.armour_percent),
|
||||
"hudEffects" => u.effects = flag(value, u.effects),
|
||||
"hudEffectsCorner" => u.effects_corner = Corner::parse(value, u.effects_corner),
|
||||
"hudHeld" => u.held = flag(value, u.held),
|
||||
"hudHeldCorner" => u.held_corner = Corner::parse(value, u.held_corner),
|
||||
"hudBackground" => u.background = flag(value, u.background),
|
||||
"hudPing" => u.ping = flag(value, u.ping),
|
||||
"hudPingCorner" => u.ping_corner = Corner::parse(value, u.ping_corner),
|
||||
"hudCoords" => u.coords = flag(value, u.coords),
|
||||
"hudCoordsCorner" => u.coords_corner = Corner::parse(value, u.coords_corner),
|
||||
"hudServer" => u.server = flag(value, u.server),
|
||||
"hudServerCorner" => u.server_corner = Corner::parse(value, u.server_corner),
|
||||
"hudCombo" => u.combo = flag(value, u.combo),
|
||||
"hudComboCorner" => u.combo_corner = Corner::parse(value, u.combo_corner),
|
||||
"numericEnchants" => t.numeric_enchants = flag(value, t.numeric_enchants),
|
||||
"lowFire" => t.fire_lower = num(value, FIRE_LOWER, t.fire_lower),
|
||||
"shieldSize" => t.shield_size = num(value, SHIELD_SIZES, t.shield_size),
|
||||
"totemSize" => t.totem_size = num(value, TOTEM_SIZES, t.totem_size),
|
||||
"glintColour" => {
|
||||
t.glint = match value {
|
||||
"off" => None,
|
||||
v => Colour::parse(v).or(t.glint),
|
||||
}
|
||||
}
|
||||
"clearWater" => t.clear_water = flag(value, t.clear_water),
|
||||
"clearLava" => t.clear_lava = flag(value, t.clear_lava),
|
||||
"chatTimestamps" => self.chat.timestamps = flag(value, self.chat.timestamps),
|
||||
"chatCompact" => self.chat.compact = flag(value, self.chat.compact),
|
||||
"chatCopy" => self.chat.copy = flag(value, self.chat.copy),
|
||||
"chatSearch" => self.chat.search = flag(value, self.chat.search),
|
||||
"viewTime" => {
|
||||
t.time = match value {
|
||||
"off" => None,
|
||||
v => ViewTime::parse(v).or(t.time),
|
||||
}
|
||||
}
|
||||
"viewWeather" => {
|
||||
t.weather = match value {
|
||||
"off" => None,
|
||||
v => ViewWeather::parse(v).or(t.weather),
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
@@ -393,6 +800,91 @@ impl Pvp {
|
||||
c.dot,
|
||||
c.hover.map_or("off", Colour::name),
|
||||
);
|
||||
let (o, h) = (&self.outline, &self.hit);
|
||||
let _ = write!(
|
||||
out,
|
||||
"outline:{}\noutlineColour:{}\noutlineOpacity:{}\noutlineThickness:{}\noutlineFill:{}\nhit:{}\nhitColour:{}\nhitOpacity:{}\n",
|
||||
o.custom,
|
||||
o.colour.name(),
|
||||
o.opacity,
|
||||
o.thickness,
|
||||
o.fill.map_or("off", Colour::name),
|
||||
h.custom,
|
||||
h.colour.name(),
|
||||
h.opacity,
|
||||
);
|
||||
let u = &self.hud;
|
||||
let _ = write!(
|
||||
out,
|
||||
"hudArmour:{}\nhudArmourCorner:{}\nhudArmourPercent:{}\nhudEffects:{}\nhudEffectsCorner:{}\nhudHeld:{}\nhudHeldCorner:{}\nhudBackground:{}\n",
|
||||
u.armour,
|
||||
u.armour_corner.name(),
|
||||
u.armour_percent,
|
||||
u.effects,
|
||||
u.effects_corner.name(),
|
||||
u.held,
|
||||
u.held_corner.name(),
|
||||
u.background,
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"hudPing:{}\nhudPingCorner:{}\nhudCoords:{}\nhudCoordsCorner:{}\nhudServer:{}\nhudServerCorner:{}\nhudCombo:{}\nhudComboCorner:{}\n",
|
||||
u.ping,
|
||||
u.ping_corner.name(),
|
||||
u.coords,
|
||||
u.coords_corner.name(),
|
||||
u.server,
|
||||
u.server_corner.name(),
|
||||
u.combo,
|
||||
u.combo_corner.name(),
|
||||
);
|
||||
let t = &self.tweaks;
|
||||
let _ = write!(
|
||||
out,
|
||||
"numericEnchants:{}\nlowFire:{}\nshieldSize:{}\ntotemSize:{}\nglintColour:{}\nclearWater:{}\nclearLava:{}\n",
|
||||
t.numeric_enchants,
|
||||
t.fire_lower,
|
||||
t.shield_size,
|
||||
t.totem_size,
|
||||
t.glint.map_or("off", Colour::name),
|
||||
t.clear_water,
|
||||
t.clear_lava,
|
||||
);
|
||||
let _ = write!(
|
||||
out,
|
||||
"viewTime:{}\nviewWeather:{}\n",
|
||||
t.time.map_or("off", ViewTime::name),
|
||||
t.weather.map_or("off", ViewWeather::name),
|
||||
);
|
||||
let c = &self.chat;
|
||||
let _ = write!(
|
||||
out,
|
||||
"chatTimestamps:{}\nchatCompact:{}\nchatCopy:{}\nchatSearch:{}\n",
|
||||
c.timestamps, c.compact, c.copy, c.search,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The official mods the PvP preset turns on when they are installed: the FPS counter and the
|
||||
/// keystrokes with clicks per second.
|
||||
pub const PRESET_MODS: [&str; 2] = ["fps_overlay", "keystrokes"];
|
||||
/// Fire overlay's place under the PvP preset (percent of its height down).
|
||||
const PRESET_FIRE_LOWER: u8 = 40;
|
||||
/// The PvP preset: the custom crosshair, the armour and effects widgets, vanilla's toggle sprint
|
||||
/// and toggle sneak, lower fire, and the FPS and keystrokes mods when installed. It only turns
|
||||
/// things on; each keeps its own settings, so it can be undone option by option.
|
||||
pub fn apply_preset(s: &mut crate::settings::Settings) {
|
||||
let p = &mut s.extras.pvp;
|
||||
p.crosshair.custom = true;
|
||||
p.hud.armour = true;
|
||||
p.hud.effects = true;
|
||||
if p.tweaks.fire_lower == 0 {
|
||||
p.tweaks.fire_lower = PRESET_FIRE_LOWER;
|
||||
}
|
||||
s.toggle_sprint = true;
|
||||
s.toggle_crouch = true;
|
||||
for id in PRESET_MODS {
|
||||
s.mods.turn_on(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +925,61 @@ opts! {
|
||||
CrosshairDot, "crosshair_dot", "Centre dot", "A dot in the middle of the crosshair.";
|
||||
CrosshairHover, "crosshair_hover", "Colour on entities",
|
||||
"Another colour while the crosshair is over a mob or a player you could hit, as vanilla's attack indicator knows it.";
|
||||
Outline, "outline", "Custom block outline", "Changes the outline around the block you look at.";
|
||||
OutlineColour, "outline_colour", "Colour", "The outline's colour. Black at 40% is vanilla's.";
|
||||
OutlineOpacity, "outline_opacity", "Opacity", "How solid the outline is.";
|
||||
OutlineThickness, "outline_thickness", "Thickness", "The outline's width, in times vanilla's.";
|
||||
OutlineFill, "outline_fill", "Fill", "Tints the faces of the block you look at, lightly.";
|
||||
Hit, "hit", "Custom hit colour",
|
||||
"Changes the colour mobs and players flash when they are hurt. Red at 30% is vanilla's.";
|
||||
HitColour, "hit_colour", "Colour", "The colour hurt mobs and players flash.";
|
||||
HitOpacity, "hit_opacity", "Opacity", "How much of the colour covers them.";
|
||||
HudArmour, "hud_armour", "Armour",
|
||||
"The armour you wear, head first, with how many uses each piece has left.";
|
||||
HudArmourCorner, "hud_armour_corner", "Armour corner", "Where the armour shows.";
|
||||
HudArmourPercent, "hud_armour_percent", "Durability in %",
|
||||
"Uses left as a percent of the piece's maximum instead of a number.";
|
||||
HudEffects, "hud_effects", "Effects",
|
||||
"Your effects with their names, levels and time left, in place of the icons at the top right.";
|
||||
HudEffectsCorner, "hud_effects_corner", "Effects corner", "Where the effects show.";
|
||||
HudHeld, "hud_held", "Held item count",
|
||||
"How many of the item in your hand your inventory holds; arrows with a bow or crossbow.";
|
||||
HudHeldCorner, "hud_held_corner", "Held count corner", "Where the count shows.";
|
||||
HudPing, "hud_ping", "Ping", "The latency the server reports for you, in milliseconds. Multiplayer only.";
|
||||
HudPingCorner, "hud_ping_corner", "Ping corner", "Where the ping shows.";
|
||||
HudCoords, "hud_coords", "Coordinates",
|
||||
"Your block position and the direction you face, as F3 shows them. Hidden when the world or server reduces F3.";
|
||||
HudCoordsCorner, "hud_coords_corner", "Coordinates corner", "Where the coordinates show.";
|
||||
HudServer, "hud_server", "Server address", "The address of the server you play on. Multiplayer only.";
|
||||
HudServerCorner, "hud_server_corner", "Server corner", "Where the address shows.";
|
||||
HudCombo, "hud_combo", "Combo counter",
|
||||
"Hits in a row the server confirmed, until you are hurt or 3 seconds pass without one.";
|
||||
HudComboCorner, "hud_combo_corner", "Combo corner", "Where the combo shows.";
|
||||
HudBackground, "hud_background", "Widget backgrounds", "A dark box behind each widget.";
|
||||
NumericEnchants, "numeric_enchants", "Enchantment levels as numbers",
|
||||
"Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.";
|
||||
FireLower, "fire_lower", "Lower fire",
|
||||
"Moves the flames you see while burning down, so they cover less of the screen.";
|
||||
ShieldSize, "shield_size", "Shield size", "The size of the shield in your hand, in first person.";
|
||||
TotemSize, "totem_size", "Totem animation size",
|
||||
"The size of the totem that fills the screen when it saves you.";
|
||||
GlintColour, "glint_colour", "Enchantment glint colour",
|
||||
"The colour of the shimmer on enchanted items and armour. Off is vanilla's purple.";
|
||||
ClearWater, "clear_water", "Clear water",
|
||||
"Under water, see as far as vanilla lets you after 30 seconds there, from the start.";
|
||||
ClearLava, "clear_lava", "Clear lava",
|
||||
"In lava, see as far as vanilla's Fire Resistance lets you, without the effect.";
|
||||
ViewTime, "view_time", "Time of day",
|
||||
"The time of day you see: the sky and its light only. The world's time goes on and nothing is sent to the server.";
|
||||
ViewWeather, "view_weather", "Weather",
|
||||
"The weather you see: rain, its light and fog only. The world's weather goes on and nothing is sent to the server.";
|
||||
ChatTimestamps, "chat_timestamps", "Timestamps", "The time each message came in, before it.";
|
||||
ChatCompact, "chat_compact", "Compact chat",
|
||||
"A message repeated in a row shows once, with how many times it came: (x3).";
|
||||
ChatCopy, "chat_copy", "Copy on click",
|
||||
"Clicking a message in the open chat copies its text, unless the click does something else.";
|
||||
ChatSearch, "chat_search", "Chat search",
|
||||
"Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops.";
|
||||
}
|
||||
/// The PvP & HUD page: headers (language keys) and options.
|
||||
pub fn rows() -> Vec<Result<Opt, &'static str>> {
|
||||
@@ -452,14 +999,73 @@ pub fn rows() -> Vec<Result<Opt, &'static str>> {
|
||||
]
|
||||
.map(Ok),
|
||||
);
|
||||
rows.push(Err("horizon.pvp.header.outline"));
|
||||
rows.extend(
|
||||
[
|
||||
Outline,
|
||||
OutlineColour,
|
||||
OutlineOpacity,
|
||||
OutlineThickness,
|
||||
OutlineFill,
|
||||
]
|
||||
.map(Ok),
|
||||
);
|
||||
rows.push(Err("horizon.pvp.header.hit"));
|
||||
rows.extend([Hit, HitColour, HitOpacity].map(Ok));
|
||||
rows.push(Err("horizon.pvp.header.hud"));
|
||||
rows.extend(
|
||||
[
|
||||
HudArmour,
|
||||
HudArmourCorner,
|
||||
HudArmourPercent,
|
||||
HudEffects,
|
||||
HudEffectsCorner,
|
||||
HudHeld,
|
||||
HudHeldCorner,
|
||||
HudPing,
|
||||
HudPingCorner,
|
||||
HudCoords,
|
||||
HudCoordsCorner,
|
||||
HudServer,
|
||||
HudServerCorner,
|
||||
HudCombo,
|
||||
HudComboCorner,
|
||||
HudBackground,
|
||||
]
|
||||
.map(Ok),
|
||||
);
|
||||
rows.push(Err("horizon.pvp.header.items"));
|
||||
rows.push(Ok(NumericEnchants));
|
||||
rows.push(Ok(GlintColour));
|
||||
rows.push(Err("horizon.pvp.header.visual"));
|
||||
rows.extend([FireLower, ShieldSize, TotemSize, ClearWater, ClearLava].map(Ok));
|
||||
rows.push(Err("horizon.pvp.header.view"));
|
||||
rows.extend([ViewTime, ViewWeather].map(Ok));
|
||||
rows.push(Err("horizon.pvp.header.chat"));
|
||||
rows.extend([ChatTimestamps, ChatCompact, ChatCopy, ChatSearch].map(Ok));
|
||||
rows
|
||||
}
|
||||
impl Opt {
|
||||
/// The switch this option belongs to.
|
||||
pub fn parent(self) -> Option<Opt> {
|
||||
use Opt::*;
|
||||
match self {
|
||||
Opt::Crosshair => None,
|
||||
_ => Some(Opt::Crosshair),
|
||||
Crosshair | Outline | Hit => None,
|
||||
OutlineColour | OutlineOpacity | OutlineThickness | OutlineFill => Some(Outline),
|
||||
HitColour | HitOpacity => Some(Hit),
|
||||
HudArmour | HudEffects | HudHeld | HudPing | HudCoords | HudServer | HudCombo
|
||||
| HudBackground | NumericEnchants => None,
|
||||
FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None,
|
||||
ViewTime | ViewWeather => None,
|
||||
ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None,
|
||||
HudPingCorner => Some(HudPing),
|
||||
HudCoordsCorner => Some(HudCoords),
|
||||
HudServerCorner => Some(HudServer),
|
||||
HudComboCorner => Some(HudCombo),
|
||||
HudArmourCorner | HudArmourPercent => Some(HudArmour),
|
||||
HudEffectsCorner => Some(HudEffects),
|
||||
HudHeldCorner => Some(HudHeld),
|
||||
_ => Some(Crosshair),
|
||||
}
|
||||
}
|
||||
pub fn switch(self, p: &Pvp) -> Option<bool> {
|
||||
@@ -468,6 +1074,24 @@ impl Opt {
|
||||
Opt::Crosshair => c.custom,
|
||||
Opt::CrosshairOutline => c.outline,
|
||||
Opt::CrosshairDot => c.dot,
|
||||
Opt::Outline => p.outline.custom,
|
||||
Opt::Hit => p.hit.custom,
|
||||
Opt::HudArmour => p.hud.armour,
|
||||
Opt::HudArmourPercent => p.hud.armour_percent,
|
||||
Opt::HudEffects => p.hud.effects,
|
||||
Opt::HudHeld => p.hud.held,
|
||||
Opt::HudBackground => p.hud.background,
|
||||
Opt::HudPing => p.hud.ping,
|
||||
Opt::HudCoords => p.hud.coords,
|
||||
Opt::HudServer => p.hud.server,
|
||||
Opt::HudCombo => p.hud.combo,
|
||||
Opt::NumericEnchants => p.tweaks.numeric_enchants,
|
||||
Opt::ClearWater => p.tweaks.clear_water,
|
||||
Opt::ClearLava => p.tweaks.clear_lava,
|
||||
Opt::ChatTimestamps => p.chat.timestamps,
|
||||
Opt::ChatCompact => p.chat.compact,
|
||||
Opt::ChatCopy => p.chat.copy,
|
||||
Opt::ChatSearch => p.chat.search,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -484,12 +1108,53 @@ impl Opt {
|
||||
Some(colour) => colour.label(),
|
||||
None => crate::lang::get_or("Off", "Off"),
|
||||
},
|
||||
Opt::OutlineColour => p.outline.colour.label(),
|
||||
Opt::OutlineOpacity => format!("{}%", p.outline.opacity),
|
||||
Opt::OutlineThickness => format!("{}x", p.outline.thickness),
|
||||
Opt::OutlineFill => match p.outline.fill {
|
||||
Some(colour) => colour.label(),
|
||||
None => crate::lang::get_or("Off", "Off"),
|
||||
},
|
||||
Opt::HitColour => p.hit.colour.label(),
|
||||
Opt::HitOpacity => format!("{}%", p.hit.opacity),
|
||||
Opt::HudArmourCorner => corner_label(p.hud.armour_corner),
|
||||
Opt::HudEffectsCorner => corner_label(p.hud.effects_corner),
|
||||
Opt::HudHeldCorner => corner_label(p.hud.held_corner),
|
||||
Opt::HudPingCorner => corner_label(p.hud.ping_corner),
|
||||
Opt::HudCoordsCorner => corner_label(p.hud.coords_corner),
|
||||
Opt::HudServerCorner => corner_label(p.hud.server_corner),
|
||||
Opt::HudComboCorner => corner_label(p.hud.combo_corner),
|
||||
Opt::FireLower => match p.tweaks.fire_lower {
|
||||
0 => crate::lang::get_or("Off", "Off"),
|
||||
v => format!("{v}%"),
|
||||
},
|
||||
Opt::ShieldSize => format!("{}%", p.tweaks.shield_size),
|
||||
Opt::TotemSize => format!("{}%", p.tweaks.totem_size),
|
||||
Opt::GlintColour => match p.tweaks.glint {
|
||||
Some(colour) => colour.label(),
|
||||
None => crate::lang::get_or("Off", "Off"),
|
||||
},
|
||||
Opt::ViewTime => p.tweaks.time.map_or_else(world_label, ViewTime::label),
|
||||
Opt::ViewWeather => p
|
||||
.tweaks
|
||||
.weather
|
||||
.map_or_else(world_label, ViewWeather::label),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
/// A click; `back` (right button, wheel up) goes the other way.
|
||||
pub fn cycle(self, p: &mut Pvp, back: bool) {
|
||||
let c = &mut p.crosshair;
|
||||
let (o, h, u) = (&mut p.outline, &mut p.hit, &mut p.hud);
|
||||
let t = &mut p.tweaks;
|
||||
let corner = |c: Corner| step(&Corner::ALL, c, back);
|
||||
// Off, then every solid colour.
|
||||
let solid_or_off = |v: Option<Colour>| {
|
||||
let all = [None]
|
||||
.into_iter()
|
||||
.chain(Colour::ALL[1..].iter().copied().map(Some));
|
||||
step(&all.collect::<Vec<_>>(), v, back)
|
||||
};
|
||||
match self {
|
||||
Opt::Crosshair => c.custom = !c.custom,
|
||||
Opt::CrosshairShape => c.shape = step(&Shape::ALL, c.shape, back),
|
||||
@@ -508,6 +1173,55 @@ impl Opt {
|
||||
.collect();
|
||||
c.hover = step(&all, c.hover, back);
|
||||
}
|
||||
Opt::Outline => o.custom = !o.custom,
|
||||
Opt::OutlineColour => o.colour = o.colour.step_solid(back),
|
||||
Opt::OutlineOpacity => o.opacity = step_by(o.opacity, OPACITIES, 10, back),
|
||||
Opt::OutlineThickness => {
|
||||
o.thickness = step_by(o.thickness, OUTLINE_THICKNESSES, 1, back)
|
||||
}
|
||||
Opt::OutlineFill => o.fill = solid_or_off(o.fill),
|
||||
Opt::Hit => h.custom = !h.custom,
|
||||
Opt::HitColour => h.colour = h.colour.step_solid(back),
|
||||
Opt::HitOpacity => h.opacity = step_by(h.opacity, HIT_OPACITIES, 10, back),
|
||||
Opt::HudArmour => u.armour = !u.armour,
|
||||
Opt::HudArmourCorner => u.armour_corner = corner(u.armour_corner),
|
||||
Opt::HudArmourPercent => u.armour_percent = !u.armour_percent,
|
||||
Opt::HudEffects => u.effects = !u.effects,
|
||||
Opt::HudEffectsCorner => u.effects_corner = corner(u.effects_corner),
|
||||
Opt::HudHeld => u.held = !u.held,
|
||||
Opt::HudHeldCorner => u.held_corner = corner(u.held_corner),
|
||||
Opt::HudBackground => u.background = !u.background,
|
||||
Opt::HudPing => u.ping = !u.ping,
|
||||
Opt::HudPingCorner => u.ping_corner = corner(u.ping_corner),
|
||||
Opt::HudCoords => u.coords = !u.coords,
|
||||
Opt::HudCoordsCorner => u.coords_corner = corner(u.coords_corner),
|
||||
Opt::HudServer => u.server = !u.server,
|
||||
Opt::HudServerCorner => u.server_corner = corner(u.server_corner),
|
||||
Opt::HudCombo => u.combo = !u.combo,
|
||||
Opt::HudComboCorner => u.combo_corner = corner(u.combo_corner),
|
||||
Opt::NumericEnchants => p.tweaks.numeric_enchants = !p.tweaks.numeric_enchants,
|
||||
Opt::FireLower => t.fire_lower = step_by(t.fire_lower, FIRE_LOWER, 10, back),
|
||||
Opt::ShieldSize => t.shield_size = step_by(t.shield_size, SHIELD_SIZES, 10, back),
|
||||
Opt::TotemSize => t.totem_size = step_by(t.totem_size, TOTEM_SIZES, 25, back),
|
||||
Opt::GlintColour => t.glint = solid_or_off(t.glint),
|
||||
Opt::ClearWater => t.clear_water = !t.clear_water,
|
||||
Opt::ClearLava => t.clear_lava = !t.clear_lava,
|
||||
Opt::ViewTime => {
|
||||
let all: Vec<_> = std::iter::once(None)
|
||||
.chain(ViewTime::ALL.map(Some))
|
||||
.collect();
|
||||
t.time = step(&all, t.time, back);
|
||||
}
|
||||
Opt::ChatTimestamps => p.chat.timestamps = !p.chat.timestamps,
|
||||
Opt::ChatCompact => p.chat.compact = !p.chat.compact,
|
||||
Opt::ChatCopy => p.chat.copy = !p.chat.copy,
|
||||
Opt::ChatSearch => p.chat.search = !p.chat.search,
|
||||
Opt::ViewWeather => {
|
||||
let all: Vec<_> = std::iter::once(None)
|
||||
.chain(ViewWeather::ALL.map(Some))
|
||||
.collect();
|
||||
t.weather = step(&all, t.weather, back);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -645,6 +1359,17 @@ mod tests {
|
||||
assert_eq!(p.crosshair.shape, Shape::T);
|
||||
assert_eq!(p.crosshair.size, 8);
|
||||
assert_eq!(p.crosshair.hover, Some(Colour::Inverted));
|
||||
// From vanilla's values, one click: the overlays' sizes wrap to their smallest.
|
||||
let t = p.tweaks;
|
||||
assert_eq!((t.fire_lower, t.shield_size, t.totem_size), (10, 50, 25));
|
||||
assert_eq!(t.glint, Some(Colour::White));
|
||||
assert!(t.clear_water && t.clear_lava);
|
||||
assert_eq!(
|
||||
(t.time, t.weather),
|
||||
(Some(ViewTime::Morning), Some(ViewWeather::Clear))
|
||||
);
|
||||
let c = p.chat;
|
||||
assert!(c.timestamps && c.compact && c.copy && c.search);
|
||||
let mut text = String::new();
|
||||
p.serialize(&mut text);
|
||||
let mut read = Pvp::default();
|
||||
@@ -662,6 +1387,107 @@ mod tests {
|
||||
assert!(p.parse_line("crosshairSize", "99") && p.crosshair.size == SIZES.1);
|
||||
assert!(p.parse_line("crosshairColour", "plaid") && p.crosshair.colour == Colour::White);
|
||||
assert!(!p.parse_line("fullbright", "true"));
|
||||
assert!(p.parse_line("totemSize", "5") && p.tweaks.totem_size == TOTEM_SIZES.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pvp_preset_turns_its_options_on_and_keeps_their_settings() {
|
||||
use crate::mod_list::{Installed, NativePage};
|
||||
let mut s = crate::settings::Settings::default();
|
||||
s.extras.pvp.crosshair.colour = Colour::Green;
|
||||
s.extras.pvp.tweaks.fire_lower = 60;
|
||||
let installed = |id: &str| Installed {
|
||||
id: id.into(),
|
||||
title: id.into(),
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev: false,
|
||||
native: Some(NativePage::default()),
|
||||
};
|
||||
s.mods.installed = vec![installed("fps_overlay"), installed("zoom")];
|
||||
s.mods.disabled.insert("fps_overlay".into());
|
||||
apply_preset(&mut s);
|
||||
let p = &s.extras.pvp;
|
||||
assert!(p.crosshair.custom && p.hud.armour && p.hud.effects);
|
||||
assert!(s.toggle_sprint && s.toggle_crouch);
|
||||
// What the player had chosen stays: the crosshair's colour, a lower fire already set.
|
||||
assert_eq!(p.crosshair.colour, Colour::Green);
|
||||
assert_eq!(p.tweaks.fire_lower, 60);
|
||||
// The FPS counter the player had turned off is on; keystrokes isn't installed: nothing.
|
||||
assert!(!s.mods.disabled.contains("fps_overlay"));
|
||||
assert!(s.mods.enabled.contains("fps_overlay") && !s.mods.enabled.contains("keystrokes"));
|
||||
let mut fresh = crate::settings::Settings::default();
|
||||
apply_preset(&mut fresh);
|
||||
assert_eq!(fresh.extras.pvp.tweaks.fire_lower, PRESET_FIRE_LOWER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_time_keeps_the_worlds_day() {
|
||||
// Day 9 at 5000: the moon's phase (the day number) stays, the time of day is the chosen.
|
||||
let world = 9 * 24000 + 5000;
|
||||
assert_eq!(view_day_time(world, None), world);
|
||||
assert_eq!(
|
||||
view_day_time(world, Some(ViewTime::Midnight)),
|
||||
9 * 24000 + 18000
|
||||
);
|
||||
assert_eq!(view_day_time(-100, Some(ViewTime::Noon)), -24000 + 6000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_glint_colour_reaches_the_shaders_as_rgb_bytes() {
|
||||
let mut t = Tweaks::default();
|
||||
assert_eq!(t.glint_texel(), 0, "vanilla's glint");
|
||||
t.glint = Some(Colour::Red);
|
||||
// unpack4x8unorm reads the low byte first: r = 1, g = 0, b = 0, a = 1.
|
||||
assert_eq!(t.glint_texel().to_le_bytes(), [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_and_hit_colours_default_to_vanillas() {
|
||||
// Turned on with their first settings, they give vanilla's colours: the outline's
|
||||
// 0x66000000 (LevelRenderer) and OverlayTexture's hurt texel 0xB2FF0000.
|
||||
let o = Outline {
|
||||
custom: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(o.stroke(), Some((0x6600_0000, 1.0)));
|
||||
assert_eq!(o.fill(), None);
|
||||
let [r, g, b, a] = crate::overlay::HURT_TEXEL;
|
||||
let hit = Hit {
|
||||
custom: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(hit.texel(), u32::from_le_bytes([r, g, b, a]));
|
||||
assert_eq!(
|
||||
(Outline::default().stroke(), Hit::default().texel()),
|
||||
(None, 0)
|
||||
);
|
||||
// Fully opaque cyan covers the entity entirely: none of its own colour stays.
|
||||
let cyan = Hit {
|
||||
custom: true,
|
||||
colour: Colour::Cyan,
|
||||
opacity: 100,
|
||||
};
|
||||
assert_eq!(cyan.texel().to_le_bytes(), [0x40, 0xFF, 0xFF, 0]);
|
||||
let mut p = Pvp::default();
|
||||
Opt::Outline.cycle(&mut p, false);
|
||||
Opt::OutlineFill.cycle(&mut p, false);
|
||||
assert_eq!(p.outline.fill(), Some(0x40FF_FFFF), "white at 25%");
|
||||
Opt::OutlineOpacity.cycle(&mut p, true);
|
||||
Opt::OutlineOpacity.cycle(&mut p, true);
|
||||
Opt::OutlineOpacity.cycle(&mut p, true);
|
||||
Opt::OutlineOpacity.cycle(&mut p, true);
|
||||
assert_eq!(p.outline.opacity, 100, "down from 10% wraps to 100%");
|
||||
Opt::OutlineColour.cycle(&mut p, true);
|
||||
assert_eq!(p.outline.colour, Colour::Magenta, "never Inverted");
|
||||
let mut text = String::new();
|
||||
p.serialize(&mut text);
|
||||
let mut read = Pvp::default();
|
||||
for line in text.lines() {
|
||||
let (k, v) = line.split_once(':').unwrap();
|
||||
assert!(read.parse_line(k, v), "{k}");
|
||||
}
|
||||
assert_eq!(read, p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -678,9 +1504,17 @@ mod tests {
|
||||
for header in rows().into_iter().filter_map(Result::err) {
|
||||
assert!(en.get(header).is_some(), "{header}");
|
||||
}
|
||||
for c in Corner::ALL {
|
||||
let key = format!("horizon.pvp.corner.{}", c.name());
|
||||
assert_eq!(en.get(&key), Some(c.label()), "{key}");
|
||||
}
|
||||
let names = Colour::ALL.map(|c| (format!("horizon.pvp.colour.{}", c.name()), c.english()));
|
||||
let shapes = Shape::ALL.map(|c| (format!("horizon.pvp.shape.{}", c.name()), c.english()));
|
||||
for (key, english) in names.iter().chain(&shapes) {
|
||||
let times = ViewTime::ALL.map(|c| (format!("horizon.pvp.time.{}", c.name()), c.english()));
|
||||
let weathers =
|
||||
ViewWeather::ALL.map(|c| (format!("horizon.pvp.weather.{}", c.name()), c.english()));
|
||||
assert_eq!(en.get("horizon.pvp.view.world"), Some("World's"));
|
||||
for (key, english) in names.iter().chain(&shapes).chain(×).chain(&weathers) {
|
||||
assert_eq!(en.get(key), Some(*english), "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! The PvP & HUD page's HUD widgets (`docs/pvp.md`): boxes of lines in the screen corners,
|
||||
//! placed by `extras_hud::Corners` beside mods' boxes. Each widget keeps its lines, their
|
||||
//! text and widths, and rebuilds them only when what it shows changes, so a frame that
|
||||
//! changes nothing allocates nothing.
|
||||
use crate::text::Text;
|
||||
|
||||
/// The icon at a line's left: an item the game draws, or an atlas sprite.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Icon {
|
||||
None,
|
||||
/// The armour slot (`Game::armor`: 0 head to 3 feet).
|
||||
Armour(u8),
|
||||
/// An inventory slot's item (`OFFHAND_SLOT` for the off hand), drawn without its count.
|
||||
Slot(usize),
|
||||
/// A sprite by its atlas name (`mob_effect/speed`), 18 pixels square.
|
||||
Sprite(String),
|
||||
}
|
||||
impl Icon {
|
||||
pub fn size(&self) -> f32 {
|
||||
match self {
|
||||
Icon::None => 0.0,
|
||||
Icon::Armour(_) | Icon::Slot(_) => 16.0,
|
||||
Icon::Sprite(_) => 18.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Line {
|
||||
pub icon: Icon,
|
||||
pub text: Text,
|
||||
/// A second, grey text under the first (an effect's time left).
|
||||
pub detail: Option<Text>,
|
||||
/// The widest of its texts, in GUI pixels.
|
||||
pub text_width: f32,
|
||||
pub colour: u32,
|
||||
}
|
||||
/// Padding inside a widget's box.
|
||||
pub const PAD: f32 = 2.0;
|
||||
const TEXT_HEIGHT: f32 = 8.0;
|
||||
/// Space between an icon and its text.
|
||||
const ICON_GAP: f32 = 2.0;
|
||||
impl Line {
|
||||
pub fn new(icon: Icon, text: String, width: &dyn Fn(&str) -> f32) -> Self {
|
||||
Self {
|
||||
icon,
|
||||
text_width: width(&text),
|
||||
text: Text::literal(text),
|
||||
detail: None,
|
||||
colour: 0xFFFF_FFFF,
|
||||
}
|
||||
}
|
||||
pub fn with_detail(mut self, detail: String, width: &dyn Fn(&str) -> f32) -> Self {
|
||||
self.text_width = self.text_width.max(width(&detail));
|
||||
self.detail = Some(Text::literal(detail));
|
||||
self
|
||||
}
|
||||
pub fn height(&self) -> f32 {
|
||||
let text = if self.detail.is_some() {
|
||||
2.0 * TEXT_HEIGHT + 2.0
|
||||
} else {
|
||||
TEXT_HEIGHT
|
||||
};
|
||||
text.max(self.icon.size()) + 2.0
|
||||
}
|
||||
/// Where the text starts, from the line's left.
|
||||
pub fn text_x(&self, icon_column: f32) -> f32 {
|
||||
if icon_column > 0.0 {
|
||||
icon_column + ICON_GAP
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One widget's lines, rebuilt from `K` (what they show) only when it changes. The caller
|
||||
/// fills [`Widget::next`] each frame (reusing its storage), then calls [`Widget::refresh`].
|
||||
#[derive(Default)]
|
||||
pub struct Widget<K> {
|
||||
shown: Option<K>,
|
||||
/// What the widget shows this frame, filled by the caller.
|
||||
pub next: K,
|
||||
pub lines: Vec<Line>,
|
||||
/// The box's size, padding included.
|
||||
pub size: (f32, f32),
|
||||
}
|
||||
impl<K: PartialEq + Default> Widget<K> {
|
||||
/// Rebuilds the lines with `build` when `next` differs from what they show; true if so.
|
||||
pub fn refresh(&mut self, build: impl FnOnce(&K, &mut Vec<Line>)) -> bool {
|
||||
if self.shown.as_ref() == Some(&self.next) {
|
||||
return false;
|
||||
}
|
||||
self.lines.clear();
|
||||
build(&self.next, &mut self.lines);
|
||||
self.size = size(&self.lines);
|
||||
let next = std::mem::take(&mut self.next);
|
||||
// The old key's storage comes back as `next` for the following frames.
|
||||
self.next = self.shown.replace(next).unwrap_or_default();
|
||||
true
|
||||
}
|
||||
/// Forget the lines (the widget was turned off).
|
||||
pub fn clear(&mut self) {
|
||||
self.shown = None;
|
||||
self.lines.clear();
|
||||
self.size = (0.0, 0.0);
|
||||
}
|
||||
}
|
||||
/// The width of the icons' column: the widest icon of the lines.
|
||||
pub fn icon_column(lines: &[Line]) -> f32 {
|
||||
lines.iter().map(|l| l.icon.size()).fold(0.0, f32::max)
|
||||
}
|
||||
/// A box around `lines`, one under the other.
|
||||
pub fn size(lines: &[Line]) -> (f32, f32) {
|
||||
if lines.is_empty() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let icons = icon_column(lines);
|
||||
let width = lines
|
||||
.iter()
|
||||
.map(|l| l.text_x(icons) + l.text_width)
|
||||
.fold(0.0, f32::max);
|
||||
let height = lines.iter().map(Line::height).sum::<f32>();
|
||||
(width + 2.0 * PAD, height + 2.0 * PAD - 2.0)
|
||||
}
|
||||
|
||||
/// How armour durability shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Durability {
|
||||
/// Uses left.
|
||||
Number,
|
||||
/// Uses left in percent of the item's maximum, rounded.
|
||||
Percent,
|
||||
}
|
||||
/// An item's durability text: none for an item without durability.
|
||||
pub fn durability(damage: u32, max: u32, mode: Durability) -> Option<String> {
|
||||
if max == 0 {
|
||||
return None;
|
||||
}
|
||||
let left = max.saturating_sub(damage);
|
||||
Some(match mode {
|
||||
Durability::Number => left.to_string(),
|
||||
Durability::Percent => format!("{}%", (left * 100 + max / 2) / max),
|
||||
})
|
||||
}
|
||||
/// The colour of a durability text: `ItemStack.getBarColor`'s hue, green full to red empty.
|
||||
pub fn durability_colour(damage: u32, max: u32) -> u32 {
|
||||
if max == 0 {
|
||||
return 0xFFFF_FFFF;
|
||||
}
|
||||
let left = max.saturating_sub(damage) as f32 / max as f32;
|
||||
// Mth.hsvToRgb(f / 3, 1, 1): from red (0) through yellow to green (1/3).
|
||||
let (r, g) = if left >= 0.5 {
|
||||
((2.0 - 2.0 * left) * 255.0, 255.0)
|
||||
} else {
|
||||
(255.0, 2.0 * left * 255.0)
|
||||
};
|
||||
0xFF00_0000 | (r as u32) << 16 | (g as u32) << 8
|
||||
}
|
||||
|
||||
/// `Direction.fromYRot`: the horizontal direction a yaw faces, as F3's "Facing" names it.
|
||||
pub fn facing(yaw: f32) -> &'static str {
|
||||
match (f64::from(yaw) / 90.0 + 0.5).floor() as i64 & 3 {
|
||||
0 => "south",
|
||||
1 => "west",
|
||||
2 => "north",
|
||||
_ => "east",
|
||||
}
|
||||
}
|
||||
|
||||
/// The combo counter: hits in a row on what this player attacked, each counted when the
|
||||
/// server reports that entity hurt (`ClientboundDamageEventPacket`) soon after the attack;
|
||||
/// back to 0 when this player is hurt or no hit lands for [`Combo::EXPIRE`] seconds.
|
||||
#[derive(Default)]
|
||||
pub struct Combo {
|
||||
count: u32,
|
||||
/// The entity attacked last and when.
|
||||
target: Option<(i32, f64)>,
|
||||
last_hit: f64,
|
||||
}
|
||||
impl Combo {
|
||||
/// How long after an attack the server's hurt report still counts it.
|
||||
pub const CONFIRM: f64 = 1.0;
|
||||
pub const EXPIRE: f64 = 3.0;
|
||||
pub fn attacked(&mut self, id: i32, now: f64) {
|
||||
self.target = Some((id, now));
|
||||
}
|
||||
/// The server reports entity `id` hurt. `by_me`: whether this player is
|
||||
/// the damage's direct entity, when the report names one (melee and
|
||||
/// spear hits do; Horizon's LAN hub names none for player victims). A
|
||||
/// report that names nobody counts only as the answer to the last swing.
|
||||
pub fn hurt(&mut self, id: i32, by_me: Option<bool>, now: f64) {
|
||||
let confirmed = match by_me {
|
||||
Some(mine) => mine,
|
||||
None => self
|
||||
.target
|
||||
.is_some_and(|(t, at)| t == id && now - at <= Self::CONFIRM),
|
||||
};
|
||||
if confirmed {
|
||||
self.target = None;
|
||||
self.count += 1;
|
||||
self.last_hit = now;
|
||||
}
|
||||
}
|
||||
/// This player was hurt.
|
||||
pub fn broken(&mut self) {
|
||||
self.count = 0;
|
||||
self.target = None;
|
||||
}
|
||||
pub fn count(&mut self, now: f64) -> u32 {
|
||||
if now - self.last_hit > Self::EXPIRE {
|
||||
self.count = 0;
|
||||
}
|
||||
self.count
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn width(s: &str) -> f32 {
|
||||
6.0 * s.chars().count() as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durability_shows_uses_left_as_a_number_or_a_percent() {
|
||||
// A diamond chestplate (528 uses) after 100 hits.
|
||||
assert_eq!(
|
||||
durability(100, 528, Durability::Number).as_deref(),
|
||||
Some("428")
|
||||
);
|
||||
assert_eq!(
|
||||
durability(100, 528, Durability::Percent).as_deref(),
|
||||
Some("81%")
|
||||
);
|
||||
assert_eq!(
|
||||
durability(0, 528, Durability::Percent).as_deref(),
|
||||
Some("100%")
|
||||
);
|
||||
assert_eq!(
|
||||
durability(528, 528, Durability::Number).as_deref(),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
durability(0, 0, Durability::Number),
|
||||
None,
|
||||
"a pumpkin on the head"
|
||||
);
|
||||
assert_eq!(durability_colour(0, 100), 0xFF00_FF00);
|
||||
assert_eq!(durability_colour(100, 100), 0xFFFF_0000);
|
||||
assert_eq!(durability_colour(50, 100), 0xFFFF_FF00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facing_follows_direction_from_y_rot() {
|
||||
// Yaw 0 looks toward +Z (south); turning right (yaw up) goes west, north, east.
|
||||
let names: Vec<_> = [0.0, 44.9, 45.0, 90.0, 180.0, -90.0, 270.0, -180.0, 720.0]
|
||||
.map(facing)
|
||||
.to_vec();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"south", "south", "west", "west", "north", "east", "east", "north", "south"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combos_count_confirmed_hits_until_this_player_is_hurt() {
|
||||
let mut c = Combo::default();
|
||||
c.attacked(7, 0.0);
|
||||
c.hurt(7, None, 0.1);
|
||||
c.attacked(7, 0.6);
|
||||
c.hurt(7, None, 0.7);
|
||||
assert_eq!(c.count(0.8), 2);
|
||||
// Another entity hurt, or a report too late after the swing, adds nothing.
|
||||
c.attacked(7, 1.0);
|
||||
c.hurt(9, None, 1.1);
|
||||
c.hurt(7, None, 2.5);
|
||||
assert_eq!(c.count(2.5), 2);
|
||||
// One report per swing: a second one (thorns, fire) for the same attack doesn't count.
|
||||
c.attacked(7, 3.0);
|
||||
c.hurt(7, None, 3.1);
|
||||
c.hurt(7, None, 3.2);
|
||||
assert_eq!(c.count(3.2), 3);
|
||||
c.broken();
|
||||
assert_eq!(c.count(3.3), 0);
|
||||
c.attacked(7, 4.0);
|
||||
c.hurt(7, None, 4.1);
|
||||
assert_eq!(c.count(7.0), 1);
|
||||
assert_eq!(c.count(7.2), 0, "more than 3 s since the last hit");
|
||||
// A report that names who hit decides alone: a spear jab (no swing
|
||||
// remembered) counts, a teammate's hit right after a swing doesn't.
|
||||
c.hurt(7, Some(true), 8.0);
|
||||
c.attacked(7, 8.5);
|
||||
c.hurt(7, Some(false), 8.6);
|
||||
assert_eq!(c.count(8.7), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_widget_rebuilds_only_when_what_it_shows_changes() {
|
||||
let mut w: Widget<Vec<u32>> = Widget::default();
|
||||
let mut builds = 0;
|
||||
for frame in 0..10 {
|
||||
w.next.clear();
|
||||
w.next.push(if frame < 5 { 7 } else { 8 });
|
||||
w.refresh(|k, lines| {
|
||||
builds += 1;
|
||||
lines.push(Line::new(Icon::None, k[0].to_string(), &width));
|
||||
});
|
||||
}
|
||||
assert_eq!(builds, 2);
|
||||
assert_eq!(w.lines[0].text.plain(), "8");
|
||||
w.clear();
|
||||
w.next.clear();
|
||||
w.next.push(8);
|
||||
assert!(w.refresh(|_, lines| lines.push(Line::new(Icon::None, "x".into(), &width))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_box_fits_its_widest_line_and_icons_line_up() {
|
||||
let lines = [
|
||||
Line::new(Icon::Armour(3), "428".into(), &width),
|
||||
Line::new(Icon::None, "12345".into(), &width),
|
||||
Line::new(
|
||||
Icon::Sprite("mob_effect/speed".into()),
|
||||
"Speed II".into(),
|
||||
&width,
|
||||
)
|
||||
.with_detail("01:30".into(), &width),
|
||||
];
|
||||
assert_eq!(icon_column(&lines), 18.0);
|
||||
// Texts start after the 18-pixel column and a 2-pixel gap: the widest is "Speed II"
|
||||
// (48 pixels).
|
||||
let (w, h) = size(&lines);
|
||||
assert_eq!(w, 2.0 + 18.0 + 2.0 + 48.0 + 2.0);
|
||||
// 18 + 2 for the icon lines (the effect's two texts take 18), 8 + 2 for the text one.
|
||||
assert_eq!(h, 18.0 + 10.0 + 20.0 + 2.0 * PAD - 2.0);
|
||||
assert!(size(&[]) == (0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ pub struct ScreenInputs<'a> {
|
||||
/// Eye in water: (lightmap brightness at the eye, yaw, pitch).
|
||||
pub water: Option<(f32, f32, f32)>,
|
||||
pub on_fire: bool,
|
||||
/// How far the fire overlay moves down, in its own heights (PvP & HUD's lower fire; 0 is
|
||||
/// vanilla's place).
|
||||
pub fire_lower: f32,
|
||||
/// `Hud.extractCameraOverlays` draws: the full-screen blits become
|
||||
/// screen quads (the spyglass, its fills and the nausea swirl do not).
|
||||
pub gui_overlays: &'a [GuiDraw],
|
||||
@@ -201,7 +204,7 @@ impl ScreenEffects {
|
||||
for (dx, angle) in [(0.24f32, -10.0f32), (-0.24, 10.0)] {
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
// rotateY about the quad then translate; z = -0.5 feeds into x.
|
||||
let corner = |x: f32, y: f32| [x * c + -0.5 * s + dx, y - 0.3];
|
||||
let corner = |x: f32, y: f32| [x * c + -0.5 * s + dx, y - 0.3 - input.fire_lower];
|
||||
let corners = [
|
||||
corner(-0.5, -0.5),
|
||||
corner(0.5, -0.5),
|
||||
@@ -347,6 +350,7 @@ mod tests {
|
||||
block_overlay: None,
|
||||
water: Some((1.0, 0.0, 0.0)),
|
||||
on_fire: false,
|
||||
fire_lower: 0.0,
|
||||
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
|
||||
};
|
||||
let quads = e.build(&input);
|
||||
@@ -401,6 +405,7 @@ mod tests {
|
||||
block_overlay: Some("block/stone".to_owned()),
|
||||
water: Some((1.0, 0.0, 0.0)),
|
||||
on_fire: false,
|
||||
fire_lower: 0.0,
|
||||
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
|
||||
});
|
||||
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
//! backend through `stopped`.
|
||||
use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::collections::HashMap;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
/// `SoundSource` indices (vanilla order).
|
||||
pub const MASTER: usize = 0;
|
||||
@@ -41,11 +44,45 @@ pub enum Attenuation {
|
||||
None,
|
||||
Linear,
|
||||
}
|
||||
/// The object an entity-bound sound follows. Clones retain the same object,
|
||||
/// even after the world replaces another entity with the same numeric id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SoundEntity {
|
||||
pub id: u32,
|
||||
state: Arc<RwLock<SoundEntityState>>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SoundEntityState {
|
||||
pub position: [f64; 3],
|
||||
pub silent: bool,
|
||||
pub removed: bool,
|
||||
}
|
||||
impl SoundEntity {
|
||||
pub fn new(id: u32, position: [f64; 3], silent: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
state: Arc::new(RwLock::new(SoundEntityState {
|
||||
position,
|
||||
silent,
|
||||
removed: false,
|
||||
})),
|
||||
}
|
||||
}
|
||||
pub fn state(&self) -> SoundEntityState {
|
||||
*self.state.read().unwrap()
|
||||
}
|
||||
pub fn update(&self, state: SoundEntityState) {
|
||||
*self.state.write().unwrap() = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// The tickable instance kinds Horizon plays (`TickableSoundInstance`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Ticking {
|
||||
/// Not tickable.
|
||||
No,
|
||||
/// `EntityBoundSoundInstance`.
|
||||
EntityBound,
|
||||
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
|
||||
BiomeLoop { fade: i32, direction: i32 },
|
||||
/// `UnderLiquidAmbientSoundInstance`.
|
||||
@@ -145,6 +182,8 @@ pub struct SoundInstance {
|
||||
pub random: LegacyRandom,
|
||||
pub can_start_silent: bool,
|
||||
pub ticking: Ticking,
|
||||
/// A stable entity reference, separate from its reusable network id.
|
||||
pub bound_entity: Option<SoundEntity>,
|
||||
/// `AbstractTickableSoundInstance.stopped`.
|
||||
pub stopped: bool,
|
||||
/// `sound` after `getOrResolve`, or the sound a caller chose for the
|
||||
@@ -184,6 +223,7 @@ impl SoundInstance {
|
||||
random,
|
||||
can_start_silent: false,
|
||||
ticking: Ticking::No,
|
||||
bound_entity: None,
|
||||
stopped: false,
|
||||
sound: None,
|
||||
event: None,
|
||||
@@ -212,6 +252,27 @@ impl SoundInstance {
|
||||
false,
|
||||
)
|
||||
}
|
||||
/// `EntityBoundSoundInstance`: initial coordinates are widened floats too.
|
||||
pub fn entity_bound(
|
||||
id: &str,
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
random: LegacyRandom,
|
||||
entity: SoundEntity,
|
||||
) -> Self {
|
||||
let position = entity.state().position.map(|v| f64::from(v as f32));
|
||||
let mut instance = Self::at(id, source, volume, pitch, random, position);
|
||||
instance.ticking = Ticking::EntityBound;
|
||||
instance.bound_entity = Some(entity);
|
||||
instance
|
||||
}
|
||||
/// `EntityBoundSoundInstance.canPlaySound`, also checked on replay.
|
||||
pub fn can_play_sound(&self) -> bool {
|
||||
self.bound_entity
|
||||
.as_ref()
|
||||
.is_none_or(|entity| !entity.state().silent)
|
||||
}
|
||||
/// `SimpleSoundInstance.forUI(sound, pitch, volume)`.
|
||||
pub fn for_ui(id: &str, pitch: f32, volume: f32, random: LegacyRandom) -> Self {
|
||||
Self::new(
|
||||
@@ -551,6 +612,18 @@ impl SoundInstance {
|
||||
pub fn tick(&mut self, ctx: &TickContext) {
|
||||
match &mut self.ticking {
|
||||
Ticking::No | Ticking::MusicLayer => {}
|
||||
Ticking::EntityBound => {
|
||||
let entity = self
|
||||
.bound_entity
|
||||
.as_ref()
|
||||
.expect("entity-bound sound target")
|
||||
.state();
|
||||
if entity.removed {
|
||||
self.stop();
|
||||
} else {
|
||||
self.position = entity.position.map(|v| f64::from(v as f32));
|
||||
}
|
||||
}
|
||||
Ticking::BiomeLoop { fade, direction } => {
|
||||
let stop = *fade < 0;
|
||||
*fade += *direction;
|
||||
@@ -966,6 +1039,13 @@ impl SoundEngine {
|
||||
pub fn category_gain(&self, source: usize) -> f32 {
|
||||
self.gain_by_source[source]
|
||||
}
|
||||
/// Live object references used by entity-bound instances, including queued ones.
|
||||
pub fn bound_entities(&self) -> Vec<SoundEntity> {
|
||||
self.instances
|
||||
.values()
|
||||
.filter_map(|s| s.bound_entity.clone())
|
||||
.collect()
|
||||
}
|
||||
/// `stop(SoundInstance)`.
|
||||
pub fn stop(&mut self, id: InstanceId, backend: &mut impl Backend) {
|
||||
if !self.loaded {
|
||||
@@ -1111,6 +1191,9 @@ impl SoundEngine {
|
||||
if !self.loaded || !self.instances.contains_key(&id) {
|
||||
return PlayResult::NotStarted;
|
||||
}
|
||||
if !self.instances[&id].can_play_sound() {
|
||||
return PlayResult::NotStarted;
|
||||
}
|
||||
self.resolve(id);
|
||||
let instance = &self.instances[&id];
|
||||
let Some(subtitle) = instance.event.clone() else {
|
||||
@@ -1250,9 +1333,14 @@ impl SoundEngine {
|
||||
self.play(id, backend);
|
||||
}
|
||||
for id in self.ticking.clone() {
|
||||
let Some(instance) = self.instances.get_mut(&id) else {
|
||||
let Some(instance) = self.instances.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
if !instance.can_play_sound() {
|
||||
// SoundEngine still ticks the instance after stopping its channel.
|
||||
self.stop(id, backend);
|
||||
}
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
instance.tick(ctx);
|
||||
if instance.stopped {
|
||||
self.stop(id, backend);
|
||||
@@ -1764,3 +1852,66 @@ mod tests {
|
||||
assert!(underwater.stopped);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod entity_bound_tests {
|
||||
use super::*;
|
||||
fn instance(entity: SoundEntity) -> SoundInstance {
|
||||
SoundInstance::entity_bound(
|
||||
"minecraft:entity.allay.item_given",
|
||||
NEUTRAL,
|
||||
0.7,
|
||||
1.2,
|
||||
LegacyRandom::new(42),
|
||||
entity,
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn an_entity_bound_sound_tracks_float_positions_and_stops_before_the_removed_position() {
|
||||
let entity = SoundEntity::new(7, [0.123456789, 64.123456789, -3.123456789], false);
|
||||
let mut sound = instance(entity.clone());
|
||||
assert_eq!(sound.position[0].to_bits(), 0x3fbf9add40000000);
|
||||
entity.update(SoundEntityState {
|
||||
position: [0.248456789, 65.25, -3.623456789],
|
||||
silent: false,
|
||||
removed: false,
|
||||
});
|
||||
sound.tick(&TickContext::default());
|
||||
assert_eq!(sound.position[0].to_bits(), 0x3fcfcd6ea0000000);
|
||||
let last = sound.position;
|
||||
entity.update(SoundEntityState {
|
||||
position: [100.0; 3],
|
||||
silent: false,
|
||||
removed: true,
|
||||
});
|
||||
sound.tick(&TickContext::default());
|
||||
assert!(sound.stopped);
|
||||
assert_eq!(sound.position, last);
|
||||
}
|
||||
#[test]
|
||||
fn silence_is_read_from_the_entity_including_before_a_replay() {
|
||||
let entity = SoundEntity::new(7, [0.0; 3], true);
|
||||
let sound = instance(entity.clone());
|
||||
assert!(!sound.can_play_sound());
|
||||
entity.update(SoundEntityState {
|
||||
position: [0.0; 3],
|
||||
silent: false,
|
||||
removed: false,
|
||||
});
|
||||
assert!(sound.can_play_sound());
|
||||
}
|
||||
#[test]
|
||||
fn reusing_a_network_id_does_not_retarget_an_old_sound() {
|
||||
let old = SoundEntity::new(7, [0.0; 3], false);
|
||||
let mut sound = instance(old.clone());
|
||||
old.update(SoundEntityState {
|
||||
position: [0.0; 3],
|
||||
silent: false,
|
||||
removed: true,
|
||||
});
|
||||
let replacement = SoundEntity::new(7, [10.0; 3], false);
|
||||
sound.tick(&TickContext::default());
|
||||
assert!(sound.stopped);
|
||||
assert_ne!(sound.position, replacement.state().position);
|
||||
}
|
||||
}
|
||||
@@ -144,8 +144,15 @@ pub fn set_labpbr_maps(on: bool) {
|
||||
pub fn labpbr_maps() -> bool {
|
||||
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// Budget for either complete normal or specular image set.
|
||||
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
|
||||
/// Budget for either complete normal or specular image set: a quarter of the block textures'
|
||||
/// budget, so raising `textureBudgetMiB` sharpens a pack's LabPBR maps along with its colours.
|
||||
fn map_bytes(texture_budget: u64) -> u64 {
|
||||
texture_budget / 4
|
||||
}
|
||||
/// [`map_bytes`] for the budget the next resource load keeps to.
|
||||
fn max_map_bytes() -> u64 {
|
||||
map_bytes(TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
|
||||
/// loading assumes at least these many share the array.
|
||||
const LAYERS_AT_LEAST: u64 = 1024;
|
||||
@@ -275,7 +282,7 @@ impl Sprites {
|
||||
self.specular.clear();
|
||||
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
|
||||
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
|
||||
side_within(l.min(size), layers, MAX_MAP_BYTES)
|
||||
side_within(l.min(size), layers, max_map_bytes())
|
||||
});
|
||||
if largest_map.is_some() {
|
||||
let pbr_size = self.pbr_size;
|
||||
@@ -783,7 +790,7 @@ fn pbr_map(
|
||||
return None;
|
||||
}
|
||||
let side = w.max(h);
|
||||
let output_side = side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES).min(side);
|
||||
let output_side = side_within(side, LAYERS_AT_LEAST, max_map_bytes()).min(side);
|
||||
let frames = (0..columns * rows)
|
||||
.map(|index| {
|
||||
let (x, y) = (index % columns * w, index / columns * h);
|
||||
@@ -856,6 +863,15 @@ mod tests {
|
||||
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
|
||||
}
|
||||
#[test]
|
||||
fn labpbr_maps_follow_the_texture_budget() {
|
||||
// 1300 layers of 1024 px maps: 64 px at the default budget, 256 px at 1536 MiB.
|
||||
assert_eq!(
|
||||
side_within(1024, 1300, map_bytes(DEFAULT_TEXTURE_BUDGET)),
|
||||
64
|
||||
);
|
||||
assert_eq!(side_within(1024, 1300, map_bytes(1536 << 20)), 256);
|
||||
}
|
||||
#[test]
|
||||
fn a_labpbr_map_selects_whole_texels_and_missing_layers_get_defaults() {
|
||||
// Selecting a texel preserves encoded channels, including categorical values.
|
||||
let frame = [
|
||||
|
||||
@@ -429,10 +429,10 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
|
||||
let step = (start + select(corner, 4u - corner, dir == 1u)) & 3u;
|
||||
let du = select(0u, 1u, step == 1u || step == 2u);
|
||||
let dw = select(0u, 1u, step >= 2u);
|
||||
var pos = block;
|
||||
pos[axis] = block[axis] + side;
|
||||
pos[(axis + 1u) % 3u] = block[(axis + 1u) % 3u] + du;
|
||||
pos[(axis + 2u) % 3u] = block[(axis + 2u) % 3u] + dw;
|
||||
// No dynamic component stores: FXC (DirectX 12) rejects them (X3500). A component's offset
|
||||
// from the axis (0: the axis, 1: the next, 2: the one after) picks what it gets.
|
||||
let rel = (vec3(0u, 1u, 2u) + vec3(3u - axis)) % vec3(3u);
|
||||
let pos = block + select(select(vec3(dw), vec3(du), rel == vec3(1u)), vec3(side), rel == vec3(0u));
|
||||
let sel = lo.z >> 24u;
|
||||
let us = (sel >> (2u * corner)) & 1u;
|
||||
let vs = (sel >> (2u * corner + 1u)) & 1u;
|
||||
@@ -512,7 +512,8 @@ fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
|
||||
}
|
||||
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
|
||||
let s = section_corner(vertex);
|
||||
return corner_vertex(s.c, slot_origins[block_slots[s.slot >> 8u]]);
|
||||
let origin = slot_origins[block_slots[s.slot >> 8u]];
|
||||
return corner_vertex(s.c, origin);
|
||||
}
|
||||
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
|
||||
let s = section_corner(vertex);
|
||||
|
||||
@@ -142,6 +142,10 @@ path = "tests/launch_api.rs"
|
||||
name = "mod_api_hooks"
|
||||
path = "tests/mod_api_hooks.rs"
|
||||
|
||||
[[example]]
|
||||
name = "native_gui_overlay_probe"
|
||||
path = "examples/native_gui_overlay_probe.rs"
|
||||
|
||||
[build-dependencies]
|
||||
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
|
||||
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"horizon.moderation.remaining": "für weitere %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s wurde von %2$s %3$s stummgeschaltet",
|
||||
"horizon.moderation.invalid_duration": "%s ist keine Zeitdauer: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <Spieler> [Grund]",
|
||||
"horizon.moderation.usage.mute": "/mute <Spieler> [Dauer] [Grund]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <Spieler> <Dauer> [Grund]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <Spieler>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
@@ -369,5 +369,8 @@
|
||||
"horizon.tempban.usage": "/tempban <Spieler> <Dauer> [Grund]",
|
||||
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
|
||||
"horizon.tempban.success": "%1$s für %2$s, bis %3$s"
|
||||
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
|
||||
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
|
||||
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
|
||||
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)"
|
||||
}
|
||||
@@ -222,6 +222,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.realms.not_signed_in": "Sign in with your Microsoft account in the Horizon launcher to play on Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
|
||||
@@ -536,7 +537,7 @@
|
||||
"horizon.moderation.remaining": "for another %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s was muted by %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s isn't a duration: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [reason]",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <player>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
@@ -548,6 +549,9 @@
|
||||
"horizon.tempban.invalid_duration": "%s isn't a duration: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Temporary bans need a dedicated server",
|
||||
"horizon.tempban.success": "%1$s for %2$s, until %3$s",
|
||||
"horizon.tempban.list.empty": "There are no temporary bans",
|
||||
"horizon.tempban.list.count": "There are %s temporary ban(s):",
|
||||
"horizon.tempban.list.entry": "%1$s was banned by %2$s %3$s (until %4$s)",
|
||||
"horizon.options.pointer_speed": "Menu Pointer Speed",
|
||||
"horizon.options.scroll_speed": "Menu Scroll Speed",
|
||||
"horizon.options.repeat_delay": "Navigation Repeat Delay",
|
||||
@@ -590,5 +594,112 @@
|
||||
"horizon.pvp.shape.x": "X",
|
||||
"horizon.pvp.shape.circle": "Circle",
|
||||
"horizon.pvp.shape.square": "Square",
|
||||
"horizon.pvp.shape.dot": "Dot"
|
||||
"horizon.pvp.shape.dot": "Dot",
|
||||
"horizon.pvp.outline": "Custom block outline",
|
||||
"horizon.pvp.outline.tooltip": "Changes the outline around the block you look at.",
|
||||
"horizon.pvp.outline_colour": "Colour",
|
||||
"horizon.pvp.outline_colour.tooltip": "The outline's colour. Black at 40% is vanilla's.",
|
||||
"horizon.pvp.outline_opacity": "Opacity",
|
||||
"horizon.pvp.outline_opacity.tooltip": "How solid the outline is.",
|
||||
"horizon.pvp.outline_thickness": "Thickness",
|
||||
"horizon.pvp.outline_thickness.tooltip": "The outline's width, in times vanilla's.",
|
||||
"horizon.pvp.outline_fill": "Fill",
|
||||
"horizon.pvp.outline_fill.tooltip": "Tints the faces of the block you look at, lightly.",
|
||||
"horizon.pvp.hit": "Custom hit colour",
|
||||
"horizon.pvp.hit.tooltip": "Changes the colour mobs and players flash when they are hurt. Red at 30% is vanilla's.",
|
||||
"horizon.pvp.hit_colour": "Colour",
|
||||
"horizon.pvp.hit_colour.tooltip": "The colour hurt mobs and players flash.",
|
||||
"horizon.pvp.hit_opacity": "Opacity",
|
||||
"horizon.pvp.hit_opacity.tooltip": "How much of the colour covers them.",
|
||||
"horizon.pvp.header.outline": "Block outline",
|
||||
"horizon.pvp.header.hit": "Hit colour",
|
||||
"horizon.pvp.hud_armour": "Armour",
|
||||
"horizon.pvp.hud_armour.tooltip": "The armour you wear, head first, with how many uses each piece has left.",
|
||||
"horizon.pvp.hud_armour_corner": "Armour corner",
|
||||
"horizon.pvp.hud_armour_corner.tooltip": "Where the armour shows.",
|
||||
"horizon.pvp.hud_armour_percent": "Durability in %",
|
||||
"horizon.pvp.hud_armour_percent.tooltip": "Uses left as a percent of the piece's maximum instead of a number.",
|
||||
"horizon.pvp.hud_effects": "Effects",
|
||||
"horizon.pvp.hud_effects.tooltip": "Your effects with their names, levels and time left, in place of the icons at the top right.",
|
||||
"horizon.pvp.hud_effects_corner": "Effects corner",
|
||||
"horizon.pvp.hud_effects_corner.tooltip": "Where the effects show.",
|
||||
"horizon.pvp.hud_held": "Held item count",
|
||||
"horizon.pvp.hud_held.tooltip": "How many of the item in your hand your inventory holds; arrows with a bow or crossbow.",
|
||||
"horizon.pvp.hud_held_corner": "Held count corner",
|
||||
"horizon.pvp.hud_held_corner.tooltip": "Where the count shows.",
|
||||
"horizon.pvp.hud_background": "Widget backgrounds",
|
||||
"horizon.pvp.hud_background.tooltip": "A dark box behind each widget.",
|
||||
"horizon.pvp.header.hud": "HUD widgets",
|
||||
"horizon.pvp.corner.top_left": "Top left",
|
||||
"horizon.pvp.corner.top_right": "Top right",
|
||||
"horizon.pvp.corner.bottom_left": "Bottom left",
|
||||
"horizon.pvp.corner.bottom_right": "Bottom right",
|
||||
"horizon.pvp.hud_ping": "Ping",
|
||||
"horizon.pvp.hud_ping.tooltip": "The latency the server reports for you, in milliseconds. Multiplayer only.",
|
||||
"horizon.pvp.hud_ping_corner": "Ping corner",
|
||||
"horizon.pvp.hud_ping_corner.tooltip": "Where the ping shows.",
|
||||
"horizon.pvp.hud_coords": "Coordinates",
|
||||
"horizon.pvp.hud_coords.tooltip": "Your block position and the direction you face, as F3 shows them. Hidden when the world or server reduces F3.",
|
||||
"horizon.pvp.hud_coords_corner": "Coordinates corner",
|
||||
"horizon.pvp.hud_coords_corner.tooltip": "Where the coordinates show.",
|
||||
"horizon.pvp.hud_server": "Server address",
|
||||
"horizon.pvp.hud_server.tooltip": "The address of the server you play on. Multiplayer only.",
|
||||
"horizon.pvp.hud_server_corner": "Server corner",
|
||||
"horizon.pvp.hud_server_corner.tooltip": "Where the address shows.",
|
||||
"horizon.pvp.hud_combo": "Combo counter",
|
||||
"horizon.pvp.hud_combo.tooltip": "Hits in a row the server confirmed, until you are hurt or 3 seconds pass without one.",
|
||||
"horizon.pvp.hud_combo_corner": "Combo corner",
|
||||
"horizon.pvp.hud_combo_corner.tooltip": "Where the combo shows.",
|
||||
"horizon.pvp.hud.ping": "Ping: %s ms",
|
||||
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
|
||||
"horizon.pvp.hud.facing": "Facing: %s",
|
||||
"horizon.pvp.hud.combo": "Combo: %s",
|
||||
"horizon.pvp.hud.direction.north": "North",
|
||||
"horizon.pvp.hud.direction.south": "South",
|
||||
"horizon.pvp.hud.direction.east": "East",
|
||||
"horizon.pvp.hud.direction.west": "West",
|
||||
"horizon.pvp.numeric_enchants": "Enchantment levels as numbers",
|
||||
"horizon.pvp.numeric_enchants.tooltip": "Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.",
|
||||
"horizon.pvp.header.items": "Items",
|
||||
"horizon.pvp.fire_lower": "Lower fire",
|
||||
"horizon.pvp.fire_lower.tooltip": "Moves the flames you see while burning down, so they cover less of the screen.",
|
||||
"horizon.pvp.shield_size": "Shield size",
|
||||
"horizon.pvp.shield_size.tooltip": "The size of the shield in your hand, in first person.",
|
||||
"horizon.pvp.totem_size": "Totem animation size",
|
||||
"horizon.pvp.totem_size.tooltip": "The size of the totem that fills the screen when it saves you.",
|
||||
"horizon.pvp.glint_colour": "Enchantment glint colour",
|
||||
"horizon.pvp.glint_colour.tooltip": "The colour of the shimmer on enchanted items and armour. Off is vanilla's purple.",
|
||||
"horizon.pvp.clear_water": "Clear water",
|
||||
"horizon.pvp.clear_water.tooltip": "Under water, see as far as vanilla lets you after 30 seconds there, from the start.",
|
||||
"horizon.pvp.clear_lava": "Clear lava",
|
||||
"horizon.pvp.clear_lava.tooltip": "In lava, see as far as vanilla's Fire Resistance lets you, without the effect.",
|
||||
"horizon.pvp.header.visual": "Visual tweaks",
|
||||
"horizon.pvp.view_time": "Time of day",
|
||||
"horizon.pvp.view_time.tooltip": "The time of day you see: the sky and its light only. The world's time goes on and nothing is sent to the server.",
|
||||
"horizon.pvp.view_weather": "Weather",
|
||||
"horizon.pvp.view_weather.tooltip": "The weather you see: rain, its light and fog only. The world's weather goes on and nothing is sent to the server.",
|
||||
"horizon.pvp.header.view": "Your view",
|
||||
"horizon.pvp.view.world": "World's",
|
||||
"horizon.pvp.time.morning": "Morning",
|
||||
"horizon.pvp.time.noon": "Noon",
|
||||
"horizon.pvp.time.sunset": "Sunset",
|
||||
"horizon.pvp.time.midnight": "Midnight",
|
||||
"horizon.pvp.weather.clear": "Clear",
|
||||
"horizon.pvp.weather.rain": "Rain",
|
||||
"horizon.pvp.weather.thunder": "Thunder",
|
||||
"horizon.chat.timestamp": "[%s] ",
|
||||
"horizon.chat.repeats": " (x%s)",
|
||||
"horizon.pvp.header.chat": "Chat",
|
||||
"horizon.chat.copied": "Message copied",
|
||||
"horizon.chat.search": "Search chat:",
|
||||
"horizon.pvp.chat_timestamps": "Timestamps",
|
||||
"horizon.pvp.chat_timestamps.tooltip": "The time each message came in, before it.",
|
||||
"horizon.pvp.chat_compact": "Compact chat",
|
||||
"horizon.pvp.chat_compact.tooltip": "A message repeated in a row shows once, with how many times it came: (x3).",
|
||||
"horizon.pvp.chat_copy": "Copy on click",
|
||||
"horizon.pvp.chat_copy.tooltip": "Clicking a message in the open chat copies its text, unless the click does something else.",
|
||||
"horizon.pvp.chat_search": "Chat search",
|
||||
"horizon.pvp.chat_search.tooltip": "Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops.",
|
||||
"horizon.pvp.preset": "PvP preset",
|
||||
"horizon.pvp.preset.tooltip": "Turns on the custom crosshair, the armour and effects widgets, toggle sprint and sneak, lower fire, and the FPS counter and keystrokes mods if installed. Your settings for each stay."
|
||||
}
|
||||
@@ -240,7 +240,7 @@
|
||||
"horizon.moderation.remaining": "durante %s más",
|
||||
"horizon.moderation.mutelist.entry": "%1$s fue silenciado por %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s no es una duración: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <jugador> [motivo]",
|
||||
"horizon.moderation.usage.mute": "/mute <jugador> [duración] [motivo]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <jugador> <duración> [motivo]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <jugador>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
@@ -251,5 +251,8 @@
|
||||
"horizon.tempban.usage": "/tempban <jugador> <duración> [motivo]",
|
||||
"horizon.tempban.invalid_duration": "%s no es una duración: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Los bloqueos temporales necesitan un servidor dedicado",
|
||||
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s"
|
||||
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s",
|
||||
"horizon.tempban.list.empty": "No hay baneos temporales",
|
||||
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
|
||||
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)"
|
||||
}
|
||||
@@ -456,7 +456,7 @@
|
||||
"horizon.moderation.remaining": "pour encore %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s a été réduit au silence par %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s n'est pas une durée : 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <joueur> [raison]",
|
||||
"horizon.moderation.usage.mute": "/mute <joueur> [durée] [raison]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <joueur> <durée> [raison]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <joueur>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
@@ -468,6 +468,9 @@
|
||||
"horizon.tempban.invalid_duration": "%s n'est pas une durée : 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Les bannissements temporaires nécessitent un serveur dédié",
|
||||
"horizon.tempban.success": "%1$s pour %2$s, jusqu'au %3$s",
|
||||
"horizon.tempban.list.empty": "Il n'y a aucun bannissement temporaire",
|
||||
"horizon.tempban.list.count": "Il y a %s bannissement(s) temporaire(s) :",
|
||||
"horizon.tempban.list.entry": "%1$s a été banni par %2$s %3$s (jusqu'au %4$s)",
|
||||
"horizon.options.pointer_speed": "Vitesse du pointeur des menus",
|
||||
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
|
||||
"horizon.options.repeat_delay": "Délai de répétition de navigation",
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.realms.not_signed_in": "Войдите в лаунчере Horizon с учётной записью Microsoft, чтобы играть в Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! A real-renderer consumer of the public GUI API. Run in a disposable game home:
|
||||
//! `cargo run -p horizon-client --example native_gui_overlay_probe --
|
||||
//! --resources PATH --world OverlayProbe --creative --world-frames 120`.
|
||||
//! Every world frame turns the camera 90 degrees; GUI callbacks assert that
|
||||
//! Host::camera already sees that frame. A magenta square stays above menus/F1.
|
||||
use horizon_client::mod_api::{
|
||||
CameraPoseEvent, GuiRenderPostEvent, Hooks, Host, HudEvent, ScreenRenderPostEvent,
|
||||
};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
struct Probe {
|
||||
frame: AtomicU32,
|
||||
yaw: AtomicU32,
|
||||
}
|
||||
|
||||
impl Probe {
|
||||
fn observe(&self, host: &dyn Host, phase: &str, flags: Option<(bool, bool)>) {
|
||||
let camera = host.camera();
|
||||
if let Some(camera) = &camera {
|
||||
assert_eq!(
|
||||
camera.yaw.to_bits(),
|
||||
self.yaw.load(Ordering::SeqCst),
|
||||
"previous frame's camera in {phase}"
|
||||
);
|
||||
assert!(
|
||||
camera
|
||||
.view
|
||||
.iter()
|
||||
.chain(camera.projection.iter())
|
||||
.all(|v| v.is_finite())
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"native_gui_probe {}",
|
||||
serde_json::json!({
|
||||
"frame": self.frame.load(Ordering::SeqCst), "phase": phase,
|
||||
"yaw": camera.map(|c| c.yaw), "flags": flags
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Hooks for Probe {
|
||||
fn camera_pose(&self, _: &dyn Host, event: &mut CameraPoseEvent<'_>) {
|
||||
let frame = self.frame.fetch_add(1, Ordering::SeqCst);
|
||||
event.yaw = [0.0_f32, 90.0, 180.0, -90.0][frame as usize % 4];
|
||||
self.yaw.store(event.yaw.to_bits(), Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn hud(&self, host: &mut dyn Host, _: &mut HudEvent<'_>) {
|
||||
self.observe(host, "hud", None);
|
||||
}
|
||||
|
||||
fn screen_render_post(&self, host: &dyn Host, event: &mut ScreenRenderPostEvent<'_>) {
|
||||
self.observe(host, event.screen.id, None);
|
||||
}
|
||||
|
||||
fn gui_render_post(&self, host: &dyn Host, event: &mut GuiRenderPostEvent<'_>) {
|
||||
let (w, h) = (event.batch.width, event.batch.height);
|
||||
host.fill(
|
||||
event.batch,
|
||||
[w * 0.5 - 8.0, h - 24.0, 16.0, 16.0],
|
||||
0xffff00ff,
|
||||
);
|
||||
self.observe(
|
||||
host,
|
||||
"gui_render_post",
|
||||
Some((event.gui_hidden, event.focused)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
horizon_client::entry::main_with(
|
||||
vec![Box::new(Probe {
|
||||
frame: AtomicU32::new(0),
|
||||
yaw: AtomicU32::new(0),
|
||||
}) as Box<dyn Hooks>]
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Client interaction state through the production Allay predictor; no prepared resources.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Anim, Brain, Flags, Mob, MobType, Part, Species,
|
||||
allay::interaction::{Interaction, predict},
|
||||
},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::{io::Write, sync::Arc};
|
||||
|
||||
fn kind() -> Arc<MobType> {
|
||||
Arc::new(MobType {
|
||||
id: "minecraft:allay".into(),
|
||||
species: Species::Allay,
|
||||
width: 0.6,
|
||||
height: 1.95,
|
||||
eye_height: 0.36,
|
||||
category: "monster".into(),
|
||||
max_health: 20.0,
|
||||
speed: 0.23,
|
||||
flying_speed: 0.4,
|
||||
attack_damage: 3.0,
|
||||
attack_knockback: 0.0,
|
||||
follow_range: 35.0,
|
||||
step_height: 0.6,
|
||||
armor: 2.0,
|
||||
armor_toughness: 0.0,
|
||||
knockback_resistance: 0.0,
|
||||
jump_strength: 0.42,
|
||||
attributes: Arc::default(),
|
||||
item_stack_limits: Arc::default(),
|
||||
gravity: 0.08,
|
||||
safe_fall_distance: 3.0,
|
||||
fall_damage_multiplier: 1.0,
|
||||
water_efficiency: 0.0,
|
||||
water_slowdown: 0.8,
|
||||
air_speed: 0.02,
|
||||
baby_scale: 0.5,
|
||||
head_rot_speed: 10.0,
|
||||
max_head_x_rot: 40.0,
|
||||
max_head_y_rot: 75.0,
|
||||
ambient_interval: 120,
|
||||
flags: Flags::default(),
|
||||
model: Arc::new(Part {
|
||||
name: "root".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}),
|
||||
layers: Default::default(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Allay,
|
||||
sounds: [""; 3],
|
||||
step_sound: None,
|
||||
food: Arc::default(),
|
||||
tempt: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn stack(s: Option<&ItemStack>) -> String {
|
||||
s.filter(|s| s.count > 0)
|
||||
.map_or_else(|| "empty".into(), |s| format!("{}x{}", s.item, s.count))
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[1])?);
|
||||
for tag in 0..3 {
|
||||
for main in [true, false] {
|
||||
for item in [
|
||||
"minecraft:air",
|
||||
"minecraft:stone",
|
||||
"minecraft:amethyst_shard",
|
||||
] {
|
||||
for holding in [false, true] {
|
||||
for dancing in [false, true] {
|
||||
for ready in [false, true] {
|
||||
for creative in [false, true] {
|
||||
let mut mob = Mob::new(37, kind(), Vec3::ZERO, 0.0);
|
||||
mob.extra.flag = dancing;
|
||||
mob.extra.egg_time = i32::from(!ready);
|
||||
if holding {
|
||||
mob.extra.set_main_hand_stack(Some(ItemStack::new(
|
||||
"minecraft:stone",
|
||||
1,
|
||||
)));
|
||||
}
|
||||
let mut offer =
|
||||
(item != "minecraft:air").then(|| ItemStack::new(item, 1));
|
||||
for step in 0..3 {
|
||||
let duplicates = offer.as_ref().is_some_and(|s| {
|
||||
(tag != 2 && &*s.item == "minecraft:amethyst_shard")
|
||||
|| (tag == 1 && &*s.item == "minecraft:stone")
|
||||
});
|
||||
let result = predict(
|
||||
&mut mob,
|
||||
offer.as_ref(),
|
||||
main,
|
||||
duplicates,
|
||||
[0, 0, 0, 11],
|
||||
);
|
||||
let sounds = result.as_ref().map_or_else(String::new, |r| {
|
||||
format!("{}:6:2.0:1.0:true", r.sound())
|
||||
});
|
||||
let mut returned = String::new();
|
||||
match result {
|
||||
Some(Interaction::Take(s)) => returned = stack(Some(&s)),
|
||||
Some(Interaction::Duplicate | Interaction::Give)
|
||||
if !creative =>
|
||||
{
|
||||
if let Some(s) = offer.as_mut() {
|
||||
s.count -= 1;
|
||||
}
|
||||
if offer.as_ref().is_some_and(|s| s.count == 0) {
|
||||
offer = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let hand = if main { "MAIN_HAND" } else { "OFF_HAND" };
|
||||
writeln!(
|
||||
out,
|
||||
"{tag} {hand} {item} {holding} {dancing} {ready} {creative} {step} => held={} offer={} can={} liked={} returned=[{returned}] sounds=[{sounds}]",
|
||||
stack(mob.extra.main_hand_stack()),
|
||||
stack(offer.as_ref()),
|
||||
mob.extra.egg_time == 0,
|
||||
mob.extra.owner.is_some()
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -119,6 +119,7 @@ pub fn main() -> Result<()> {
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
health: &resources.health,
|
||||
}
|
||||
.update_auto_jump(fl(&c["xa"]), fl(&c["za"]))?;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Captured level sounds through the production packet builder and protocol codec.
|
||||
use horizon_client::{audio::CapturedSound, lan::containers::captured_sound_packet};
|
||||
use horizon_protocol::{buf::Reader, packets::play::Clientbound as C};
|
||||
use std::io::Write;
|
||||
|
||||
pub fn main() {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[2]).unwrap());
|
||||
for line in std::fs::read_to_string(&args[1]).unwrap().lines() {
|
||||
let c: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
let float =
|
||||
|key: &str| f32::from_bits(u32::from_str_radix(c[key].as_str().unwrap(), 16).unwrap());
|
||||
let position = std::array::from_fn(|i| {
|
||||
f64::from_bits(u64::from_str_radix(c["position"][i].as_str().unwrap(), 16).unwrap())
|
||||
});
|
||||
let sound = CapturedSound {
|
||||
event: "fixture:bound".into(),
|
||||
position,
|
||||
volume: float("volume"),
|
||||
pitch: float("pitch"),
|
||||
source: c["source"].as_u64().unwrap() as usize,
|
||||
entity: c["bound"]
|
||||
.as_bool()
|
||||
.unwrap()
|
||||
.then(|| c["entity"].as_i64().unwrap() as u32),
|
||||
except_entity: None,
|
||||
};
|
||||
let (packet, range) = captured_sound_packet(
|
||||
&sound,
|
||||
c["sound_id"].as_i64().map(|n| n as i32),
|
||||
c["seed"].as_i64().unwrap(),
|
||||
);
|
||||
let kind = match packet {
|
||||
C::SoundEntity(_) => "entity",
|
||||
C::Sound(_) => "position",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let bytes = packet.encode();
|
||||
let mut reader = Reader::new(&bytes);
|
||||
let id = reader.varint().unwrap();
|
||||
// The native StreamCodec writes the payload only; also require our own
|
||||
// decoder to consume this packet fully before comparing payload bytes.
|
||||
let prefix = bytes.len() - reader.remaining();
|
||||
C::decode(id, &bytes[prefix..]).unwrap();
|
||||
let payload: String = bytes[prefix..].iter().map(|b| format!("{b:02x}")).collect();
|
||||
writeln!(out, "{kind} {:016x} {payload}", range.to_bits()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Late trident pairing and subsequent updates through the production tracker.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
lan::entity::{Entities, EntityState, Riding, Tracker, Viewer},
|
||||
};
|
||||
use horizon_protocol::{
|
||||
buf::Reader,
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::Clientbound as C,
|
||||
};
|
||||
use std::io::Write;
|
||||
|
||||
fn dump(out: &mut impl Write, index: usize, phase: &str, packets: &mut Vec<Vec<u8>>) -> Result<()> {
|
||||
let mut encoded = Vec::new();
|
||||
for bytes in packets.drain(..) {
|
||||
let mut r = Reader::new(&bytes);
|
||||
let id = r.varint()?;
|
||||
let payload = r.rest();
|
||||
let name = match C::decode(id, payload)? {
|
||||
C::AddEntity(_) => "add",
|
||||
C::SetEntityMotion(_) => "motion",
|
||||
C::MoveEntityPosRot(_) => "posrot",
|
||||
C::SetEntityData(_) => "data",
|
||||
other => panic!("unexpected late-pair packet {other:?}"),
|
||||
};
|
||||
let hex: String = payload.iter().map(|b| format!("{b:02x}")).collect();
|
||||
encoded.push(format!("{name}:{hex}"));
|
||||
}
|
||||
writeln!(out, "{index} {phase} {}", encoded.join(","))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let kind: i32 = args[1].parse()?;
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[2])?);
|
||||
writeln!(out, "type {kind}")?;
|
||||
let mut index = 0;
|
||||
for position in [[0.5, 65.5, 0.5], [-0.123456789, 64.125, -0.5]] {
|
||||
for velocity in [[0.25, 0.0, 0.0], [0.0; 3], [0.25, -0.5, 0.125]] {
|
||||
for landed in [false, true] {
|
||||
let mut level = Entities::default();
|
||||
let mut tracker = Tracker::default();
|
||||
let mut packets = Vec::new();
|
||||
let chunks = [(0, 0), (-1, -1), (0, -1), (-1, 0)].into_iter().collect();
|
||||
let mut viewers = [Viewer {
|
||||
entity_id: 1,
|
||||
spectator: false,
|
||||
permission: 0,
|
||||
moved: false,
|
||||
position: [0.5, 64.0, 0.5],
|
||||
view_distance: 10,
|
||||
chunks: &chunks,
|
||||
tracker: &mut tracker,
|
||||
out: &mut packets,
|
||||
gone: None,
|
||||
}];
|
||||
let riding = Riding::default();
|
||||
level.tick(&[], &riding, &mut viewers, |_| Some(kind));
|
||||
let mut e = EntityState {
|
||||
id: 37,
|
||||
uuid: 37,
|
||||
kind: "minecraft:trident".into(),
|
||||
position,
|
||||
velocity,
|
||||
..EntityState::default()
|
||||
};
|
||||
level.pair_after_tick(std::slice::from_ref(&e), &riding, &mut viewers, |_| {
|
||||
Some(kind)
|
||||
});
|
||||
dump(&mut out, index, "pair", viewers[0].out)?;
|
||||
e.position[0] += 0.25;
|
||||
e.velocity = [0.0; 3];
|
||||
if landed {
|
||||
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
level.tick(&[&e], &riding, &mut viewers, |_| Some(kind));
|
||||
dump(&mut out, index, "change", viewers[0].out)?;
|
||||
level.tick(&[&e], &riding, &mut viewers, |_| Some(kind));
|
||||
dump(&mut out, index, "again", viewers[0].out)?;
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod dispatch;
|
||||
|
||||
use dispatch::{args, args_os};
|
||||
|
||||
mod allay_interaction_fixture;
|
||||
mod ambient_fixture;
|
||||
mod arrow_probe;
|
||||
mod audio_attributes_fixture;
|
||||
@@ -41,6 +42,7 @@ mod entity_anim_fixture;
|
||||
mod entity_components_probe;
|
||||
mod entity_death_loot_fixture;
|
||||
mod entity_particles_probe;
|
||||
mod entity_sound_packet_fixture;
|
||||
mod environment_fixture;
|
||||
mod finalize_spawn_fixture;
|
||||
mod fire_starter_sound_probe;
|
||||
@@ -57,6 +59,7 @@ mod inline_object_fixture;
|
||||
mod input_probe;
|
||||
mod interaction_input_fixture;
|
||||
mod lan_probe;
|
||||
mod late_entity_pair_fixture;
|
||||
mod light_parity;
|
||||
mod lore_tooltip_fixture;
|
||||
mod marker_particles_probe;
|
||||
@@ -87,6 +90,7 @@ mod scoreboard_fixture;
|
||||
mod screen_layout_fixture;
|
||||
mod section_mesh_fixture;
|
||||
mod server_files_probe;
|
||||
mod sheep_dye_fixture;
|
||||
mod situational_music_fixture;
|
||||
mod skin_image_fixture;
|
||||
mod sound_engine_fixture;
|
||||
@@ -191,6 +195,10 @@ probes! {
|
||||
server_files_probe,
|
||||
situational_music_fixture,
|
||||
skin_image_fixture,
|
||||
late_entity_pair_fixture,
|
||||
sheep_dye_fixture,
|
||||
allay_interaction_fixture,
|
||||
entity_sound_packet_fixture,
|
||||
sound_engine_fixture,
|
||||
sound_event_fixture,
|
||||
spawn_rules_fixture,
|
||||
|
||||
@@ -1611,7 +1611,14 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
let _ = writeln!(out, "{t} leads {dropped_leads}");
|
||||
}
|
||||
for e in &events {
|
||||
if let MobEvent::Sound(name, _, volume, pitch) = e {
|
||||
if let MobEvent::Sound(name, _, volume, pitch)
|
||||
| MobEvent::EntitySound {
|
||||
name,
|
||||
volume,
|
||||
pitch,
|
||||
..
|
||||
} = e
|
||||
{
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{t} sound minecraft:{name} {} {}",
|
||||
|
||||
@@ -324,6 +324,7 @@ pub fn main() -> Result<()> {
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
health: &resources.health,
|
||||
};
|
||||
match op["mode"].as_str().unwrap() {
|
||||
"shift" => world.player.shift_key_down = op["value"].as_bool().unwrap(),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Actual production sheep-dye interaction without prepared resources.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
mobs::{Anim, Brain, Flags, Mob, MobType, Part, Species, sheep_dye},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{io::Write, sync::Arc};
|
||||
|
||||
fn kind() -> Arc<MobType> {
|
||||
Arc::new(MobType {
|
||||
id: "minecraft:sheep".into(),
|
||||
species: Species::Sheep,
|
||||
width: 0.6,
|
||||
height: 1.95,
|
||||
eye_height: 0.36,
|
||||
category: "monster".into(),
|
||||
max_health: 20.0,
|
||||
speed: 0.23,
|
||||
flying_speed: 0.4,
|
||||
attack_damage: 3.0,
|
||||
attack_knockback: 0.0,
|
||||
follow_range: 35.0,
|
||||
step_height: 0.6,
|
||||
armor: 2.0,
|
||||
armor_toughness: 0.0,
|
||||
knockback_resistance: 0.0,
|
||||
jump_strength: 0.42,
|
||||
attributes: Arc::default(),
|
||||
item_stack_limits: Arc::default(),
|
||||
gravity: 0.08,
|
||||
safe_fall_distance: 3.0,
|
||||
fall_damage_multiplier: 1.0,
|
||||
water_efficiency: 0.0,
|
||||
water_slowdown: 0.8,
|
||||
air_speed: 0.02,
|
||||
baby_scale: 0.5,
|
||||
head_rot_speed: 10.0,
|
||||
max_head_x_rot: 40.0,
|
||||
max_head_y_rot: 75.0,
|
||||
ambient_interval: 120,
|
||||
flags: Flags::default(),
|
||||
model: Arc::new(Part {
|
||||
name: "root".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: Vec::new(),
|
||||
children: Vec::new(),
|
||||
}),
|
||||
layers: Default::default(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Quadruped,
|
||||
sounds: [""; 3],
|
||||
step_sound: None,
|
||||
food: Arc::default(),
|
||||
tempt: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[1])?);
|
||||
for initial in [0, 7, 14, 15] {
|
||||
for hand in ["MAIN_HAND", "OFF_HAND"] {
|
||||
for item in [
|
||||
"minecraft:white_dye",
|
||||
"minecraft:red_dye",
|
||||
"minecraft:black_dye",
|
||||
] {
|
||||
for patch in 0..3 {
|
||||
for sheared in [false, true] {
|
||||
for alive in [false, true] {
|
||||
for client in [false, true] {
|
||||
let mut mob = Mob::new(37, kind(), Vec3::ZERO, 0.0);
|
||||
mob.extra.color = initial;
|
||||
mob.extra.sheared = sheared;
|
||||
if !alive {
|
||||
mob.health = 0.0;
|
||||
}
|
||||
let mut stack = ItemStack::new(item, 2);
|
||||
if patch == 1 {
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:dye",
|
||||
Some(Tag::String("blue".into())),
|
||||
);
|
||||
}
|
||||
if patch == 2 {
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:dye",
|
||||
Some(Tag::Compound(Default::default())),
|
||||
);
|
||||
}
|
||||
for step in 0..2 {
|
||||
let result = sheep_dye::interact(&mut mob, &mut stack, client);
|
||||
let sound = if result {
|
||||
"minecraft:item.dye.use:7:1.0:1.0:true"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
"{initial} {hand} {item} {patch} {sheared} {alive} {client} {step} => result={result} color={} count={} sounds=[{sound}]",
|
||||
mob.extra.color, stack.count
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
use crate::audio_common as common;
|
||||
use common::{Fake, d, describe, f, flush, result_name};
|
||||
use horizon_client::sound_engine::{
|
||||
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundInstance, TickContext,
|
||||
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundEntity, SoundEntityState,
|
||||
SoundInstance, TickContext,
|
||||
};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
@@ -21,6 +22,7 @@ pub fn main() {
|
||||
// Script cart names to entity ids (a removed cart leaves the level).
|
||||
let mut cart_ids: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut removed: BTreeMap<u32, CartSound> = BTreeMap::new();
|
||||
let mut bound_entities: BTreeMap<String, SoundEntity> = BTreeMap::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
@@ -51,6 +53,14 @@ pub fn main() {
|
||||
"loop" => SoundInstance::biome_loop(&event, random),
|
||||
"uloop" => SoundInstance::under_liquid_loop(&event, random),
|
||||
"usub" => SoundInstance::under_liquid_sub(&event, random),
|
||||
"bound" => SoundInstance::entity_bound(
|
||||
&event,
|
||||
source(4),
|
||||
f(a[5]),
|
||||
f(a[6]),
|
||||
random,
|
||||
bound_entities[a[15]].clone(),
|
||||
),
|
||||
"cart" | "rcart" | "rcartu" => {
|
||||
let id = cart_ids[a[3]];
|
||||
let cart = ctx
|
||||
@@ -109,7 +119,7 @@ pub fn main() {
|
||||
ctx.player_underwater = a[1] == "1";
|
||||
ctx.player_removed = a[2] == "1";
|
||||
}
|
||||
"cart" => {
|
||||
"cart" | "cartnew" => {
|
||||
let next = cart_ids.len() as u32 + 1;
|
||||
let id = *cart_ids.entry(a[1].to_owned()).or_insert(next);
|
||||
ctx.carts.retain(|c| c.id != id);
|
||||
@@ -119,6 +129,22 @@ pub fn main() {
|
||||
// Vec3.horizontalDistance.
|
||||
speed: (d(a[5]) * d(a[5]) + d(a[7]) * d(a[7])).sqrt(),
|
||||
};
|
||||
let silent = a.get(9) == Some(&"1");
|
||||
if a[0] == "cartnew"
|
||||
&& let Some(old) = bound_entities.remove(a[1])
|
||||
{
|
||||
let mut state = old.state();
|
||||
state.removed = true;
|
||||
old.update(state);
|
||||
}
|
||||
let entity = bound_entities
|
||||
.entry(a[1].to_owned())
|
||||
.or_insert_with(|| SoundEntity::new(id, cart.position, silent));
|
||||
entity.update(SoundEntityState {
|
||||
position: cart.position,
|
||||
silent,
|
||||
removed: a[8] == "1",
|
||||
});
|
||||
if a[8] == "1" {
|
||||
// Out of the level, still there for a later play.
|
||||
removed.insert(id, cart);
|
||||
|
||||
@@ -65,6 +65,78 @@ pub struct Tree {
|
||||
/// The triggers with a criterion that may test what the player looks at
|
||||
/// (`index_looking_at`).
|
||||
looking_at_triggers: BTreeSet<String>,
|
||||
/// The triggers with a criterion that may test an entity's `nbt`
|
||||
/// (`index_nbt`).
|
||||
nbt_triggers: BTreeSet<String>,
|
||||
/// What the criteria of each trigger may ask of the player's level,
|
||||
/// statistics, advancements and recipes (`index_player_facts`).
|
||||
player_fact_triggers: HashMap<String, FactsAsked>,
|
||||
}
|
||||
/// Which of `PlayerPredicate`'s level, `stats`, `advancements` and `recipes`
|
||||
/// a trigger's criteria may test: only those are filled in its player view.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FactsAsked {
|
||||
pub level: bool,
|
||||
pub stats: bool,
|
||||
pub advancements: bool,
|
||||
pub recipes: bool,
|
||||
}
|
||||
impl FactsAsked {
|
||||
pub const ALL: Self = Self {
|
||||
level: true,
|
||||
stats: true,
|
||||
advancements: true,
|
||||
recipes: true,
|
||||
};
|
||||
/// Asked by either.
|
||||
pub fn or(self, other: Self) -> Self {
|
||||
Self {
|
||||
level: self.level || other.level,
|
||||
stats: self.stats || other.stats,
|
||||
advancements: self.advancements || other.advancements,
|
||||
recipes: self.recipes || other.recipes,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What `v` (a predicate, a list of conditions or an item modifier) may ask
|
||||
/// of the player's level, statistics, advancements and recipes: the fields
|
||||
/// of its player predicates, and all four for a `minecraft:reference` to a
|
||||
/// predicate file. Walked without reading anything else, for the commands
|
||||
/// that test a predicate on the player.
|
||||
pub fn facts_asked_in(v: &Value) -> FactsAsked {
|
||||
let mut asked = FactsAsked::default();
|
||||
let mut todo = vec![v];
|
||||
while let Some(v) = todo.pop() {
|
||||
match v {
|
||||
Value::Array(list) => todo.extend(list),
|
||||
Value::Object(o) => {
|
||||
// A predicate file, or (`function`) an item modifier file.
|
||||
let kind =
|
||||
(o.get("condition").or_else(|| o.get("type"))).or_else(|| o.get("function"));
|
||||
if kind
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|k| full(k) == "minecraft:reference")
|
||||
{
|
||||
return FactsAsked::ALL;
|
||||
}
|
||||
for (key, value) in o {
|
||||
if full(key) == "minecraft:type_specific/player"
|
||||
&& let Some(p) = value.as_object()
|
||||
{
|
||||
asked = asked.or(FactsAsked {
|
||||
level: p.contains_key("level"),
|
||||
stats: p.contains_key("stats"),
|
||||
advancements: p.contains_key("advancements"),
|
||||
recipes: p.contains_key("recipes"),
|
||||
});
|
||||
}
|
||||
todo.push(value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
asked
|
||||
}
|
||||
fn frame(v: &Value) -> Frame {
|
||||
match v.as_str() {
|
||||
@@ -146,6 +218,8 @@ impl Tree {
|
||||
}
|
||||
tree.index_recipes();
|
||||
tree.index_looking_at();
|
||||
tree.index_nbt();
|
||||
tree.index_player_facts();
|
||||
tree
|
||||
}
|
||||
/// Index the `recipe_unlocked` criteria by recipe: a grant that unlocks
|
||||
@@ -188,6 +262,69 @@ impl Tree {
|
||||
pub fn may_look(&self, trigger: &str) -> bool {
|
||||
self.looking_at_triggers.contains(trigger)
|
||||
}
|
||||
/// Index the triggers whose criteria name `nbt`, or a
|
||||
/// `minecraft:reference` to a predicate file that might: the text is
|
||||
/// searched, nothing is read.
|
||||
pub fn index_nbt(&mut self) {
|
||||
let keys = [
|
||||
"\"nbt\"",
|
||||
"\"minecraft:nbt\"",
|
||||
"\"minecraft:reference\"",
|
||||
"\"reference\"",
|
||||
];
|
||||
self.nbt_triggers = (self.list.iter().flat_map(|a| &a.criteria))
|
||||
.filter(|c| {
|
||||
let json = c.conditions.json();
|
||||
keys.iter().any(|k| json.contains(k))
|
||||
})
|
||||
.map(|c| c.trigger.clone())
|
||||
.collect();
|
||||
}
|
||||
/// Whether a criterion of `trigger` may test an entity's `nbt`.
|
||||
pub fn may_test_nbt(&self, trigger: &str) -> bool {
|
||||
self.nbt_triggers.contains(trigger)
|
||||
}
|
||||
/// Whether any criterion may test an entity's `nbt`.
|
||||
pub fn tests_nbt(&self) -> bool {
|
||||
!self.nbt_triggers.is_empty()
|
||||
}
|
||||
/// Index what each trigger's criteria may ask of the player's level,
|
||||
/// statistics, advancements and recipes: a player predicate naming the
|
||||
/// field, or a `minecraft:reference` to a predicate file that might (all
|
||||
/// four). The text is searched, nothing is read.
|
||||
pub fn index_player_facts(&mut self) {
|
||||
let reference = ["\"minecraft:reference\"", "\"reference\""];
|
||||
let mut out: HashMap<String, FactsAsked> = HashMap::new();
|
||||
for c in self.list.iter().flat_map(|a| &a.criteria) {
|
||||
let json = c.conditions.json();
|
||||
let asked = if reference.iter().any(|r| json.contains(r)) {
|
||||
FactsAsked::ALL
|
||||
} else if json.contains("type_specific/player") {
|
||||
FactsAsked {
|
||||
level: json.contains("\"level\""),
|
||||
stats: json.contains("\"stats\""),
|
||||
advancements: json.contains("\"advancements\""),
|
||||
recipes: json.contains("\"recipes\""),
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if asked == FactsAsked::default() {
|
||||
continue;
|
||||
}
|
||||
let e = out.entry(c.trigger.clone()).or_default();
|
||||
*e = e.or(asked);
|
||||
}
|
||||
self.player_fact_triggers = out;
|
||||
}
|
||||
/// What a criterion of `trigger` may ask of the player's level,
|
||||
/// statistics, advancements and recipes.
|
||||
pub fn player_facts_asked(&self, trigger: &str) -> FactsAsked {
|
||||
self.player_fact_triggers
|
||||
.get(trigger)
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// The `recipe_unlocked` criteria that could pass for `recipe`, in tree
|
||||
/// order (the others name another recipe).
|
||||
pub fn recipe_criteria(&self, recipe: &str) -> Vec<(usize, usize)> {
|
||||
@@ -504,6 +641,9 @@ pub struct EntityView {
|
||||
/// Player: the sneak input is held (`Input.shift`, what `input.sneak`
|
||||
/// asks; not the sneaking pose).
|
||||
pub input_sneak: bool,
|
||||
/// Player: its level, statistics, advancements and recipes, as far as
|
||||
/// they were filled.
|
||||
pub player_facts: PlayerFacts,
|
||||
/// Lightning: entities it struck.
|
||||
pub struck: Vec<EntityView>,
|
||||
/// Lightning: blocks it set on fire (`LightningBolt.blocksSetOnFire`).
|
||||
@@ -512,6 +652,37 @@ pub struct EntityView {
|
||||
pub variant: Option<String>,
|
||||
/// The data components the entity reports (`Entity.get`): id and value.
|
||||
pub components: Vec<(String, String)>,
|
||||
/// The entity as saved (`NbtPredicate.getEntityTagToCompare`), for
|
||||
/// `minecraft:nbt`; built only when a criterion may ask (`Tree::tests_nbt`).
|
||||
pub nbt: Option<horizon_world::nbt::Tag>,
|
||||
}
|
||||
/// What `PlayerPredicate` reads of a player beyond its entity. Each part is
|
||||
/// filled only when a criterion may ask for it (`Tree::player_facts_asked`);
|
||||
/// a predicate that asks for a part left `None` fails.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PlayerFacts {
|
||||
/// `Player.experienceLevel`.
|
||||
pub level: Option<i32>,
|
||||
/// `ServerStatsCounter`: stat type -> stat id -> value, both namespaced.
|
||||
pub stats: Option<BTreeMap<String, BTreeMap<String, i32>>>,
|
||||
/// `PlayerAdvancements`: the loaded advancements and the player's
|
||||
/// progress in them.
|
||||
pub advancements: Option<(Arc<Tree>, Progress)>,
|
||||
/// `ServerRecipeBook`: the known recipe ids.
|
||||
pub recipes: Option<BTreeSet<String>>,
|
||||
}
|
||||
impl std::fmt::Debug for PlayerFacts {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PlayerFacts")
|
||||
.field("level", &self.level)
|
||||
.field("stats", &self.stats)
|
||||
.field(
|
||||
"advancements",
|
||||
&self.advancements.as_ref().map(|(_, p)| p.criteria.len()),
|
||||
)
|
||||
.field("recipes", &self.recipes.as_ref().map(BTreeSet::len))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
fn range_f(v: &Value, x: f64) -> bool {
|
||||
match v {
|
||||
@@ -535,6 +706,14 @@ pub fn full(id: &str) -> std::borrow::Cow<'_, str> {
|
||||
format!("minecraft:{id}").into()
|
||||
}
|
||||
}
|
||||
/// The entry of a map keyed by registry ids (`Identifier.parse` on the key),
|
||||
/// such as an item predicate's `components` or `predicates`.
|
||||
fn keyed<'a>(map: &'a Value, id: &str) -> Option<&'a Value> {
|
||||
map.as_object()?
|
||||
.iter()
|
||||
.find(|(k, _)| full(k) == id)
|
||||
.map(|(_, v)| v)
|
||||
}
|
||||
/// A registry id, `#tag`, or list matched against one id.
|
||||
pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
match v {
|
||||
@@ -607,18 +786,18 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
|
||||
}
|
||||
// Data components: exact matches on the few the data uses.
|
||||
let comps = &v["components"];
|
||||
if let Some(p) = comps["minecraft:potion_contents"].as_str()
|
||||
&& it.potion.as_deref() != Some(p)
|
||||
if let Some(p) = keyed(comps, "minecraft:potion_contents").and_then(Value::as_str)
|
||||
&& !it.potion.as_deref().is_some_and(|h| full(h) == full(p))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(d) = comps["minecraft:damage"].as_u64()
|
||||
if let Some(d) = keyed(comps, "minecraft:damage").and_then(Value::as_u64)
|
||||
&& u64::from(it.damage) != d
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let preds = &v["predicates"];
|
||||
if let Some(list) = preds["minecraft:enchantments"].as_array() {
|
||||
if let Some(list) = keyed(preds, "minecraft:enchantments").and_then(Value::as_array) {
|
||||
for e in list {
|
||||
let ok = it.enchantments.iter().any(|(id, lvl)| {
|
||||
id_matches(&e["enchantments"], "enchantment", id, w)
|
||||
@@ -629,7 +808,7 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(d) = preds["minecraft:damage"].as_object() {
|
||||
if let Some(d) = keyed(preds, "minecraft:damage").and_then(Value::as_object) {
|
||||
let damage = Value::Object(d.clone());
|
||||
if !range_i(&damage["damage"], i64::from(it.damage)) {
|
||||
return false;
|
||||
@@ -814,6 +993,13 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
})
|
||||
})
|
||||
}),
|
||||
// `EntityNbtPredicate.matches`: the entity's compound holds the
|
||||
// pattern (`NbtUtils.compareNbt`); no compound known fails.
|
||||
"minecraft:nbt" => p.as_str().is_some_and(|s| {
|
||||
horizon_world::nbt::parse_snbt(s).is_ok_and(|(pattern, _)| {
|
||||
crate::nbt_path::compare_nbt(&pattern, e.nbt.as_ref())
|
||||
})
|
||||
}),
|
||||
"minecraft:type_specific/player" => {
|
||||
// `PlayerPredicate.matches`: `instanceof ServerPlayer` first,
|
||||
// then the game mode, what it looks at and its `input`.
|
||||
@@ -828,6 +1014,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
.as_ref()
|
||||
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
|
||||
&& (p["input"].is_null() || input(&p["input"], e))
|
||||
&& player_facts(p, &e.player_facts)
|
||||
}
|
||||
"minecraft:type_specific/lightning" => {
|
||||
range_i(&p["blocks_set_on_fire"], i64::from(e.blocks_set_on_fire))
|
||||
@@ -849,6 +1036,89 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The rest of `PlayerPredicate.matches` (expected from it): the experience
|
||||
/// level in the `level` bounds; each `stats` entry's value (0 when never
|
||||
/// counted) in its bounds; each listed recipe known or not as asked; each
|
||||
/// listed advancement loaded, and done or not, or each named criterion of it
|
||||
/// granted or not. A field asked of facts that weren't filled fails.
|
||||
fn player_facts(p: &Value, f: &PlayerFacts) -> bool {
|
||||
let level = &p["level"];
|
||||
if !level.is_null() && !f.level.is_some_and(|l| range_i(level, i64::from(l))) {
|
||||
return false;
|
||||
}
|
||||
let stats = &p["stats"];
|
||||
if !stats.is_null() {
|
||||
let Some(list) = stats.as_array() else {
|
||||
return false;
|
||||
};
|
||||
// `StatMatcher.matches`: the counter's value for the type's stat.
|
||||
let ok = list.is_empty()
|
||||
|| f.stats.as_ref().is_some_and(|counts| {
|
||||
list.iter().all(|m| {
|
||||
let (Some(ty), Some(stat)) = (m["type"].as_str(), m["stat"].as_str()) else {
|
||||
return false;
|
||||
};
|
||||
let value = counts
|
||||
.get(&*full(ty))
|
||||
.and_then(|of| of.get(&*full(stat)))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
range_i(&m["value"], i64::from(value))
|
||||
})
|
||||
});
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let recipes = &p["recipes"];
|
||||
if !recipes.is_null() {
|
||||
let Some(map) = recipes.as_object() else {
|
||||
return false;
|
||||
};
|
||||
let ok = map.is_empty()
|
||||
|| f.recipes.as_ref().is_some_and(|known| {
|
||||
map.iter().all(|(id, want)| {
|
||||
want.as_bool()
|
||||
.is_some_and(|want| known.contains(&*full(id)) == want)
|
||||
})
|
||||
});
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let advancements = &p["advancements"];
|
||||
if !advancements.is_null() {
|
||||
let Some(map) = advancements.as_object() else {
|
||||
return false;
|
||||
};
|
||||
let ok = map.is_empty()
|
||||
|| f.advancements.as_ref().is_some_and(|(tree, progress)| {
|
||||
map.iter().all(|(id, want)| {
|
||||
// An advancement that isn't loaded matches nothing.
|
||||
let Some(a) = tree.get(&full(id)) else {
|
||||
return false;
|
||||
};
|
||||
match want {
|
||||
// `AdvancementDonePredicate`.
|
||||
Value::Bool(want) => progress.done(a) == *want,
|
||||
// `AdvancementCriterionsPredicate`: a criterion the
|
||||
// advancement doesn't have matches nothing.
|
||||
Value::Object(criteria) => criteria.iter().all(|(name, want)| {
|
||||
want.as_bool().is_some_and(|want| {
|
||||
a.criteria.iter().any(|c| c.name == *name)
|
||||
&& progress.has(&a.id, name) == want
|
||||
})
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
});
|
||||
if !ok {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `InputPredicate.matches`: the player's held inputs. The sneak input is
|
||||
/// what the game holds; the other inputs have no source here yet and count
|
||||
/// as unconstrained.
|
||||
@@ -892,6 +1162,56 @@ pub fn killed_by_arrow(
|
||||
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
|
||||
range_i(&c["unique_entity_types"], kinds.len() as i64)
|
||||
}
|
||||
/// `KilledTrigger.TriggerInstance.matches`: the player, the other entity
|
||||
/// (the one killed, or the killer for `entity_killed_player`) and the
|
||||
/// killing blow (none: any `killing_blow` fails).
|
||||
pub fn killed(
|
||||
c: &Value,
|
||||
me: &EntityView,
|
||||
target: &EntityView,
|
||||
blow: Option<&DamageView>,
|
||||
w: &dyn World,
|
||||
) -> bool {
|
||||
let pctx = Entities {
|
||||
this: Some(me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let ectx = Entities {
|
||||
this: Some(target),
|
||||
..pctx
|
||||
};
|
||||
let blow_ok = c["killing_blow"].is_null()
|
||||
|| blow.is_some_and(|d| damage_source(&c["killing_blow"], d, w, me.position));
|
||||
conditions(&c["player"], &pctx, w) && conditions(&c["entity"], &ectx, w) && blow_ok
|
||||
}
|
||||
/// A blow recorded by its damage type and causing entity, as `killing_blow`
|
||||
/// sees it: the projectile those types name is the direct entity, at `at`;
|
||||
/// otherwise the causing entity dealt it itself (`DamageSource.isDirect`).
|
||||
pub fn recorded_blow(kind: String, source: Option<EntityView>, at: [f64; 3]) -> DamageView {
|
||||
let projectile = match kind.as_str() {
|
||||
"minecraft:arrow" => Some("minecraft:arrow"),
|
||||
"minecraft:fireball" | "minecraft:small_fireball" => Some("minecraft:fireball"),
|
||||
"minecraft:wither_skull" => Some("minecraft:wither_skull"),
|
||||
"minecraft:fireworks" => Some("minecraft:firework_rocket"),
|
||||
_ => None,
|
||||
};
|
||||
DamageView {
|
||||
kind,
|
||||
is_direct: projectile.is_none(),
|
||||
direct: match projectile {
|
||||
Some(kind) => Some(EntityView {
|
||||
kind: kind.into(),
|
||||
position: at,
|
||||
..Default::default()
|
||||
}),
|
||||
None => source.clone(),
|
||||
},
|
||||
source,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// A damage event as `DamagePredicate` sees it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DamageView {
|
||||
@@ -1357,6 +1677,7 @@ impl Tree {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn advancement_reward_function_is_optional_and_namespaced() {
|
||||
@@ -1579,6 +1900,58 @@ mod tests {
|
||||
let none = serde_json::json!({});
|
||||
assert!(can_unlock(&none, false, None, &Flat));
|
||||
}
|
||||
/// `ItemPredicate`'s `components` and `predicates` are keyed by registry
|
||||
/// ids, so a key without a namespace is a `minecraft:` one. An unread key
|
||||
/// used to be skipped, which let every stack pass.
|
||||
#[test]
|
||||
fn item_predicate_reads_bare_component_keys() {
|
||||
let potion = ItemView {
|
||||
id: "minecraft:potion".into(),
|
||||
count: 1,
|
||||
potion: Some("minecraft:swiftness".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let sword = ItemView {
|
||||
id: "minecraft:iron_sword".into(),
|
||||
count: 1,
|
||||
damage: 3,
|
||||
enchantments: vec![("minecraft:sharpness".into(), 2)],
|
||||
..Default::default()
|
||||
};
|
||||
let cases = [
|
||||
(
|
||||
serde_json::json!({"components": {"potion_contents": "minecraft:swiftness"}}),
|
||||
serde_json::json!({"components": {"potion_contents": "minecraft:healing"}}),
|
||||
&potion,
|
||||
),
|
||||
(
|
||||
serde_json::json!({"components": {"damage": 3}}),
|
||||
serde_json::json!({"components": {"damage": 4}}),
|
||||
&sword,
|
||||
),
|
||||
(
|
||||
serde_json::json!({"predicates": {"enchantments": [
|
||||
{"enchantments": "sharpness", "levels": 2}
|
||||
]}}),
|
||||
serde_json::json!({"predicates": {"enchantments": [
|
||||
{"enchantments": "sharpness", "levels": {"min": 3}}
|
||||
]}}),
|
||||
&sword,
|
||||
),
|
||||
(
|
||||
serde_json::json!({"predicates": {"damage": {"damage": {"max": 3}}}}),
|
||||
serde_json::json!({"predicates": {"damage": {"damage": {"min": 4}}}}),
|
||||
&sword,
|
||||
),
|
||||
];
|
||||
for (yes, no, stack) in cases {
|
||||
assert!(item(&yes, stack, &Flat), "{yes} should match");
|
||||
assert!(!item(&no, stack, &Flat), "{no} should not match");
|
||||
}
|
||||
// The potion is a registry id too.
|
||||
let bare = serde_json::json!({"components": {"minecraft:potion_contents": "swiftness"}});
|
||||
assert!(item(&bare, &potion, &Flat));
|
||||
}
|
||||
/// `PassengerPredicate.matches`: ride_a_boat_with_a_goat's vehicle holds a goat.
|
||||
#[test]
|
||||
fn passenger_predicate_needs_a_matching_passenger() {
|
||||
@@ -1597,6 +1970,72 @@ mod tests {
|
||||
boat.passengers.push(entity("minecraft:goat"));
|
||||
assert!(super::entity(&want, &boat, &Flat, [0.0; 3]));
|
||||
}
|
||||
/// `EntityNbtPredicate.matches`: the entity's saved compound holds the
|
||||
/// pattern (`NbtUtils.compareNbt(…, true)`); without one it fails.
|
||||
#[test]
|
||||
fn an_entity_predicates_nbt_tests_the_saved_compound() {
|
||||
let snbt = |s: &str| horizon_world::nbt::parse_snbt(s).unwrap().0;
|
||||
let view = |kind: &str, nbt: &str| EntityView {
|
||||
kind: kind.into(),
|
||||
nbt: Some(snbt(nbt)),
|
||||
..Default::default()
|
||||
};
|
||||
let check = |v: serde_json::Value, e: &EntityView| super::entity(&v, e, &Flat, [0.0; 3]);
|
||||
let nbt = |s: &str| serde_json::json!({"minecraft:nbt": s});
|
||||
let axolotl = view("minecraft:axolotl", "{Variant:1,FromBucket:1b}");
|
||||
assert!(check(nbt("{Variant:1}"), &axolotl));
|
||||
assert!(check(nbt("{}"), &axolotl));
|
||||
assert!(!check(nbt("{Variant:0}"), &axolotl));
|
||||
assert!(
|
||||
!check(nbt("{FromBucket:1}"), &axolotl),
|
||||
"a byte, not an int"
|
||||
);
|
||||
let pig = serde_json::json!({"minecraft:entity_type": "minecraft:pig",
|
||||
"minecraft:nbt": "{Variant:1}"});
|
||||
assert!(
|
||||
!check(pig, &axolotl),
|
||||
"the other sub-predicates still apply"
|
||||
);
|
||||
let villager = view(
|
||||
"minecraft:villager",
|
||||
r#"{VillagerData:{level:2,profession:"minecraft:farmer",type:"minecraft:taiga"}}"#,
|
||||
);
|
||||
assert!(check(
|
||||
nbt(r#"{VillagerData:{profession:"minecraft:farmer"}}"#),
|
||||
&villager
|
||||
));
|
||||
assert!(!check(
|
||||
nbt(r#"{VillagerData:{profession:"minecraft:librarian"}}"#),
|
||||
&villager
|
||||
));
|
||||
let player = view(
|
||||
"minecraft:player",
|
||||
r#"{Inventory:[{Slot:0b,id:"minecraft:spyglass",count:1},{Slot:3b,id:"minecraft:stick",count:2}],SelectedItem:{id:"minecraft:spyglass",count:1}}"#,
|
||||
);
|
||||
assert!(check(
|
||||
nbt(r#"{Inventory:[{id:"minecraft:stick"}]}"#),
|
||||
&player
|
||||
));
|
||||
assert!(!check(
|
||||
nbt(r#"{Inventory:[{id:"minecraft:stick",count:3}]}"#),
|
||||
&player
|
||||
));
|
||||
assert!(
|
||||
!check(nbt("{Inventory:[]}"), &player),
|
||||
"an empty list wants an empty one"
|
||||
);
|
||||
assert!(check(
|
||||
nbt(r#"{SelectedItem:{id:"minecraft:spyglass"}}"#),
|
||||
&player
|
||||
));
|
||||
let bare = EntityView {
|
||||
kind: "minecraft:axolotl".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!check(nbt("{}"), &bare), "no compound known");
|
||||
assert!(!check(nbt("{Variant:"), &axolotl), "unparsable");
|
||||
assert!(!check(serde_json::json!({"minecraft:nbt": 3}), &axolotl));
|
||||
}
|
||||
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
|
||||
/// projectile, kills it for the advancement; a sword or an arrow does not.
|
||||
#[test]
|
||||
@@ -1634,6 +2073,81 @@ mod tests {
|
||||
assert!(!damage_source(&blow, &arrow, &Flat, [0.0; 3]));
|
||||
assert!(damage_source(&Value::Null, &sword, &Flat, [0.0; 3]));
|
||||
}
|
||||
fn mob(kind: &str) -> EntityView {
|
||||
EntityView {
|
||||
kind: kind.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
/// `entity_killed_player` by a mob's melee hit: the mob is the killer and
|
||||
/// the blow's causing and direct entity.
|
||||
#[test]
|
||||
fn entity_killed_player_by_a_mob_names_the_mob() {
|
||||
let c = serde_json::json!({
|
||||
"entity": {"minecraft:entity_type": "minecraft:polar_bear"},
|
||||
"killing_blow": {"source_entity": {"minecraft:entity_type": "minecraft:polar_bear"}}
|
||||
});
|
||||
let me = mob("minecraft:player");
|
||||
let bear = mob("minecraft:polar_bear");
|
||||
let blow = recorded_blow("minecraft:mob_attack".into(), Some(bear.clone()), [0.0; 3]);
|
||||
assert!(blow.is_direct);
|
||||
assert_eq!(
|
||||
blow.direct.as_ref().map(|e| e.kind.as_str()),
|
||||
Some("minecraft:polar_bear")
|
||||
);
|
||||
assert!(killed(&c, &me, &bear, Some(&blow), &Flat));
|
||||
let panda = mob("minecraft:panda");
|
||||
let blow = recorded_blow("minecraft:mob_attack".into(), Some(panda.clone()), [0.0; 3]);
|
||||
assert!(!killed(&c, &me, &panda, Some(&blow), &Flat));
|
||||
}
|
||||
/// `entity_killed_player` by a skeleton's arrow: the shooter is the
|
||||
/// killer and the blow's causing entity, the arrow its direct one.
|
||||
#[test]
|
||||
fn entity_killed_player_by_an_arrow_names_the_shooter() {
|
||||
let c = serde_json::json!({
|
||||
"entity": {"minecraft:entity_type": "minecraft:skeleton"},
|
||||
"killing_blow": {
|
||||
"direct_entity": {"minecraft:entity_type": "minecraft:arrow"},
|
||||
"source_entity": {"minecraft:entity_type": "minecraft:skeleton"},
|
||||
"is_direct": false
|
||||
}
|
||||
});
|
||||
let me = mob("minecraft:player");
|
||||
let skeleton = mob("minecraft:skeleton");
|
||||
let blow = recorded_blow(
|
||||
"minecraft:arrow".into(),
|
||||
Some(skeleton.clone()),
|
||||
[1.0, 2.0, 3.0],
|
||||
);
|
||||
assert!(!blow.is_direct);
|
||||
assert_eq!(
|
||||
blow.direct.as_ref().map(|e| e.position),
|
||||
Some([1.0, 2.0, 3.0])
|
||||
);
|
||||
assert!(killed(&c, &me, &skeleton, Some(&blow), &Flat));
|
||||
// Shot point-blank, not with an arrow: the direct entity is the skeleton.
|
||||
let melee = recorded_blow(
|
||||
"minecraft:mob_attack".into(),
|
||||
Some(skeleton.clone()),
|
||||
[0.0; 3],
|
||||
);
|
||||
assert!(!killed(&c, &me, &skeleton, Some(&melee), &Flat));
|
||||
}
|
||||
/// A blow with no causing entity fails a `source_entity` test, and with
|
||||
/// no blow at all any `killing_blow` fails.
|
||||
#[test]
|
||||
fn a_blow_with_no_killer_fails_its_entity_tests() {
|
||||
let c = serde_json::json!({
|
||||
"killing_blow": {"source_entity": {"minecraft:entity_type": "minecraft:zombie"}}
|
||||
});
|
||||
let me = mob("minecraft:player");
|
||||
let zombie = mob("minecraft:zombie");
|
||||
let fall = recorded_blow("minecraft:fall".into(), None, [0.0; 3]);
|
||||
assert!(fall.direct.is_none() && fall.source.is_none());
|
||||
assert!(!killed(&c, &me, &zombie, Some(&fall), &Flat));
|
||||
assert!(!killed(&c, &me, &zombie, None, &Flat));
|
||||
assert!(killed(&serde_json::json!({}), &me, &zombie, None, &Flat));
|
||||
}
|
||||
#[test]
|
||||
fn uh_oh_accepts_either_tnt_event_only_for_adult_sulfur_cubes() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
|
||||
@@ -2060,6 +2574,345 @@ mod tests {
|
||||
assert_eq!(l[4], Some((1, 2.0)));
|
||||
assert_eq!(l[1], Some((2, 2.0)));
|
||||
}
|
||||
/// A tree of the given advancements, indexed as `load_with` does.
|
||||
fn tree_of(list: &[(&str, Value)]) -> Tree {
|
||||
let mut tree = Tree::default();
|
||||
for (id, v) in list {
|
||||
let a = parse(id, v).expect("an advancement");
|
||||
for (ci, c) in a.criteria.iter().enumerate() {
|
||||
(tree.by_trigger.entry(c.trigger.clone()).or_default()).push((tree.list.len(), ci));
|
||||
}
|
||||
tree.by_id.insert((*id).into(), tree.list.len());
|
||||
tree.list.push(a);
|
||||
}
|
||||
tree.index_player_facts();
|
||||
tree
|
||||
}
|
||||
/// Whether the player `facts` match `{"type_specific/player": p}`.
|
||||
fn player_matches(p: Value, facts: &PlayerFacts) -> bool {
|
||||
let me = EntityView {
|
||||
kind: "minecraft:player".into(),
|
||||
player_facts: facts.clone(),
|
||||
..EntityView::default()
|
||||
};
|
||||
entity(&json!({"type_specific/player": p}), &me, &Flat, [0.0; 3])
|
||||
}
|
||||
#[test]
|
||||
fn player_level_is_tested_in_its_bounds() {
|
||||
let five = PlayerFacts {
|
||||
level: Some(5),
|
||||
..PlayerFacts::default()
|
||||
};
|
||||
for (level, expected) in [
|
||||
(json!({"min": 5}), true),
|
||||
(json!({"min": 6}), false),
|
||||
(json!({"max": 4}), false),
|
||||
(json!({"min": 1, "max": 5}), true),
|
||||
(json!(5), true),
|
||||
(json!(6), false),
|
||||
] {
|
||||
assert_eq!(
|
||||
player_matches(json!({"level": level}), &five),
|
||||
expected,
|
||||
"{level}"
|
||||
);
|
||||
}
|
||||
// Not filled: a level asked fails, none asked passes.
|
||||
assert!(!player_matches(
|
||||
json!({"level": {"min": 0}}),
|
||||
&PlayerFacts::default()
|
||||
));
|
||||
assert!(player_matches(json!({}), &PlayerFacts::default()));
|
||||
}
|
||||
#[test]
|
||||
fn player_stats_match_each_counter_by_type_and_id() {
|
||||
let mut counts: BTreeMap<String, BTreeMap<String, i32>> = BTreeMap::new();
|
||||
(counts.entry("minecraft:custom".into()).or_default())
|
||||
.insert("minecraft:walk_one_cm".into(), 1000);
|
||||
(counts.entry("minecraft:mined".into()).or_default()).insert("minecraft:stone".into(), 3);
|
||||
let facts = PlayerFacts {
|
||||
stats: Some(counts),
|
||||
..PlayerFacts::default()
|
||||
};
|
||||
let stat =
|
||||
|ty: &str, id: &str, value: Value| json!({"type": ty, "stat": id, "value": value});
|
||||
for (stats, expected) in [
|
||||
(
|
||||
vec![stat(
|
||||
"minecraft:custom",
|
||||
"minecraft:walk_one_cm",
|
||||
json!({"min": 1000}),
|
||||
)],
|
||||
true,
|
||||
),
|
||||
(
|
||||
vec![stat(
|
||||
"minecraft:custom",
|
||||
"minecraft:walk_one_cm",
|
||||
json!({"min": 1001}),
|
||||
)],
|
||||
false,
|
||||
),
|
||||
(
|
||||
vec![stat(
|
||||
"minecraft:custom",
|
||||
"minecraft:walk_one_cm",
|
||||
json!({"max": 999}),
|
||||
)],
|
||||
false,
|
||||
),
|
||||
// Bare ids are `minecraft:` ones.
|
||||
(
|
||||
vec![stat("custom", "walk_one_cm", json!({"min": 1000}))],
|
||||
true,
|
||||
),
|
||||
// The same id under another type is another counter: never counted, 0.
|
||||
(
|
||||
vec![stat(
|
||||
"minecraft:mined",
|
||||
"minecraft:walk_one_cm",
|
||||
json!({"min": 1}),
|
||||
)],
|
||||
false,
|
||||
),
|
||||
(
|
||||
vec![stat(
|
||||
"minecraft:mined",
|
||||
"minecraft:walk_one_cm",
|
||||
json!({"max": 0}),
|
||||
)],
|
||||
true,
|
||||
),
|
||||
(
|
||||
vec![stat("minecraft:killed", "minecraft:zombie", json!(0))],
|
||||
true,
|
||||
),
|
||||
// Every entry must match.
|
||||
(
|
||||
vec![
|
||||
stat("minecraft:mined", "minecraft:stone", json!({"min": 3})),
|
||||
stat(
|
||||
"minecraft:custom",
|
||||
"minecraft:walk_one_cm",
|
||||
json!({"min": 5000}),
|
||||
),
|
||||
],
|
||||
false,
|
||||
),
|
||||
(
|
||||
vec![
|
||||
stat("minecraft:mined", "minecraft:stone", json!({"min": 3})),
|
||||
stat(
|
||||
"minecraft:custom",
|
||||
"minecraft:walk_one_cm",
|
||||
json!({"min": 500}),
|
||||
),
|
||||
],
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let p = json!({"stats": stats});
|
||||
assert_eq!(player_matches(p.clone(), &facts), expected, "{p}");
|
||||
}
|
||||
let walked = json!({"stats": [stat("custom", "walk_one_cm", json!({"min": 0}))]});
|
||||
assert!(!player_matches(walked, &PlayerFacts::default()));
|
||||
}
|
||||
#[test]
|
||||
fn player_recipes_are_known_or_not_as_asked() {
|
||||
let facts = PlayerFacts {
|
||||
recipes: Some(["minecraft:iron_pickaxe".to_owned()].into()),
|
||||
..PlayerFacts::default()
|
||||
};
|
||||
for (recipes, expected) in [
|
||||
(json!({"minecraft:iron_pickaxe": true}), true),
|
||||
(json!({"iron_pickaxe": true}), true),
|
||||
(json!({"minecraft:iron_pickaxe": false}), false),
|
||||
(json!({"minecraft:diamond_pickaxe": false}), true),
|
||||
(json!({"minecraft:diamond_pickaxe": true}), false),
|
||||
(
|
||||
json!({"minecraft:iron_pickaxe": true, "minecraft:diamond_pickaxe": true}),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
player_matches(json!({"recipes": recipes}), &facts),
|
||||
expected,
|
||||
"{recipes}"
|
||||
);
|
||||
}
|
||||
let unknown = json!({"recipes": {"minecraft:diamond_pickaxe": false}});
|
||||
assert!(!player_matches(unknown, &PlayerFacts::default()));
|
||||
}
|
||||
#[test]
|
||||
fn player_advancements_are_done_or_have_their_criteria_as_asked() {
|
||||
let tick = json!({"trigger": "minecraft:tick"});
|
||||
let tree = Arc::new(tree_of(&[(
|
||||
"pack:a",
|
||||
json!({"criteria": {"x": tick, "y": tick}}),
|
||||
)]));
|
||||
let mut progress = Progress::default();
|
||||
progress.grant("pack:a", "x", "2026-11-04 12:00:00 +0000");
|
||||
let half = PlayerFacts {
|
||||
advancements: Some((tree.clone(), progress.clone())),
|
||||
..PlayerFacts::default()
|
||||
};
|
||||
for (advancements, expected) in [
|
||||
(json!({"pack:a": true}), false),
|
||||
(json!({"pack:a": false}), true),
|
||||
(json!({"pack:a": {"x": true}}), true),
|
||||
(json!({"pack:a": {"x": false}}), false),
|
||||
(json!({"pack:a": {"y": true}}), false),
|
||||
(json!({"pack:a": {"y": false}}), true),
|
||||
(json!({"pack:a": {"x": true, "y": true}}), false),
|
||||
// A criterion the advancement doesn't have, or an advancement
|
||||
// that isn't loaded, matches nothing.
|
||||
(json!({"pack:a": {"z": false}}), false),
|
||||
(json!({"pack:missing": false}), false),
|
||||
] {
|
||||
assert_eq!(
|
||||
player_matches(json!({"advancements": advancements}), &half),
|
||||
expected,
|
||||
"{advancements}"
|
||||
);
|
||||
}
|
||||
progress.grant("pack:a", "y", "2026-11-04 12:00:01 +0000");
|
||||
let done = PlayerFacts {
|
||||
advancements: Some((tree, progress)),
|
||||
..PlayerFacts::default()
|
||||
};
|
||||
assert!(player_matches(
|
||||
json!({"advancements": {"pack:a": true}}),
|
||||
&done
|
||||
));
|
||||
assert!(!player_matches(
|
||||
json!({"advancements": {"pack:a": false}}),
|
||||
&done
|
||||
));
|
||||
let asked = json!({"advancements": {"pack:a": false}});
|
||||
assert!(!player_matches(asked, &PlayerFacts::default()));
|
||||
}
|
||||
#[test]
|
||||
fn player_facts_are_asked_by_the_triggers_that_name_them() {
|
||||
let player = |p: Value| {
|
||||
json!({"player": [{"condition": "minecraft:entity_properties", "entity": "this",
|
||||
"predicate": {"minecraft:type_specific/player": p}}]})
|
||||
};
|
||||
let tree = tree_of(&[
|
||||
(
|
||||
"pack:walk",
|
||||
json!({"criteria": {"c": {"trigger": "minecraft:tick",
|
||||
"conditions": player(json!({"stats": [{"type": "minecraft:custom",
|
||||
"stat": "minecraft:walk_one_cm", "value": {"min": 1}}]}))}}}),
|
||||
),
|
||||
(
|
||||
"pack:milestone",
|
||||
json!({"criteria": {"c": {"trigger": "minecraft:location",
|
||||
"conditions": player(json!({"advancements": {"pack:walk": true},
|
||||
"level": {"min": 30}}))}}}),
|
||||
),
|
||||
(
|
||||
"pack:ref",
|
||||
json!({"criteria": {"c": {"trigger": "minecraft:inventory_changed",
|
||||
"conditions": {"player": [{"condition": "minecraft:reference",
|
||||
"name": "pack:p"}]}}}}),
|
||||
),
|
||||
(
|
||||
"pack:recipe",
|
||||
json!({"criteria": {"c": {"trigger": "minecraft:recipe_unlocked",
|
||||
"conditions": {"recipe": "minecraft:iron_pickaxe"}},
|
||||
"d": {"trigger": "minecraft:using_item",
|
||||
"conditions": player(json!({"gamemode": ["survival"]}))}}}),
|
||||
),
|
||||
]);
|
||||
let none = FactsAsked::default();
|
||||
assert_eq!(
|
||||
tree.player_facts_asked("minecraft:tick"),
|
||||
FactsAsked {
|
||||
stats: true,
|
||||
..none
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
tree.player_facts_asked("minecraft:location"),
|
||||
FactsAsked {
|
||||
advancements: true,
|
||||
level: true,
|
||||
..none
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
tree.player_facts_asked("minecraft:inventory_changed"),
|
||||
FactsAsked::ALL
|
||||
);
|
||||
assert_eq!(tree.player_facts_asked("minecraft:recipe_unlocked"), none);
|
||||
assert_eq!(tree.player_facts_asked("minecraft:using_item"), none);
|
||||
}
|
||||
#[test]
|
||||
fn a_command_predicate_asks_only_the_player_facts_it_names() {
|
||||
let player = |p: Value| {
|
||||
json!({"condition": "minecraft:entity_properties", "entity": "this",
|
||||
"predicate": {"type_specific/player": p}})
|
||||
};
|
||||
let none = FactsAsked::default();
|
||||
for (v, expected) in [
|
||||
// Nothing of the player's records: nothing copied.
|
||||
(
|
||||
json!({"condition": "minecraft:entity_properties", "entity": "this",
|
||||
"predicate": {"flags": {"is_sneaking": true}}}),
|
||||
none,
|
||||
),
|
||||
(player(json!({"gamemode": ["survival"]})), none),
|
||||
(
|
||||
json!({"condition": "minecraft:random_chance", "chance": 0.5}),
|
||||
none,
|
||||
),
|
||||
// A field named elsewhere than in a player predicate.
|
||||
(
|
||||
json!({"function": "minecraft:enchant_with_levels", "levels": 30,
|
||||
"conditions": [{"condition": "minecraft:inverted",
|
||||
"term": {"condition": "minecraft:entity_properties", "entity": "this",
|
||||
"predicate": {"minecraft:effects": {"minecraft:speed": {}}}}}]}),
|
||||
none,
|
||||
),
|
||||
(
|
||||
player(json!({"level": {"min": 3}})),
|
||||
FactsAsked {
|
||||
level: true,
|
||||
..none
|
||||
},
|
||||
),
|
||||
// Nested in a list, under `inverted`, in an item modifier.
|
||||
(
|
||||
json!([{"function": "minecraft:set_count", "count": 2, "conditions": [
|
||||
{"condition": "minecraft:inverted", "term": player(json!({
|
||||
"stats": [], "recipes": {"minecraft:bread": true}}))}]}]),
|
||||
FactsAsked {
|
||||
stats: true,
|
||||
recipes: true,
|
||||
..none
|
||||
},
|
||||
),
|
||||
(
|
||||
json!({"condition": "minecraft:all_of", "terms": [player(json!(
|
||||
{"advancements": {"minecraft:story/root": true}}))]}),
|
||||
FactsAsked {
|
||||
advancements: true,
|
||||
..none
|
||||
},
|
||||
),
|
||||
// A predicate file could ask anything.
|
||||
(
|
||||
json!({"condition": "reference", "name": "pack:p"}),
|
||||
FactsAsked::ALL,
|
||||
),
|
||||
(
|
||||
json!([{"function": "minecraft:reference", "name": "pack:m"}]),
|
||||
FactsAsked::ALL,
|
||||
),
|
||||
] {
|
||||
assert_eq!(facts_asked_in(&v), expected, "{v}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn saved_dates_read_back_to_their_instant() {
|
||||
for secs in [0, 951_782_400, 1_792_216_800, 4_107_542_399] {
|
||||
|
||||
@@ -18,11 +18,12 @@ pub use horizon_client_render::{
|
||||
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, par, particles,
|
||||
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, screen_draw, screen_effects,
|
||||
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
|
||||
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
|
||||
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
|
||||
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
|
||||
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
|
||||
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
|
||||
world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -111,6 +112,7 @@ pub mod pregen;
|
||||
pub mod projectiles;
|
||||
pub mod raid;
|
||||
pub mod random_tick_sections;
|
||||
pub mod realms;
|
||||
pub mod recipe_book;
|
||||
pub mod recipe_book_screen;
|
||||
pub mod reconnect;
|
||||
@@ -128,6 +130,7 @@ pub mod sprint;
|
||||
pub mod stations;
|
||||
pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod studio;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
@@ -136,6 +139,7 @@ pub mod toasts;
|
||||
pub mod ui_theme;
|
||||
pub mod vanillagen;
|
||||
pub mod win_screen;
|
||||
pub mod world_primitives;
|
||||
pub mod world_spawn;
|
||||
pub use horizon_client_core::Result;
|
||||
pub use horizon_client_core::block_entity_nbt;
|
||||
|
||||
@@ -227,7 +227,10 @@ impl Click {
|
||||
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
|
||||
let s = |key| string(t.get(key));
|
||||
Some(match path(kind) {
|
||||
"open_url" => Self::OpenUrl(s("url")?),
|
||||
// ClickEvent.OpenUrl's url is an ExtraCodecs.UNTRUSTED_URI.
|
||||
"open_url" => {
|
||||
Self::OpenUrl(s("url").filter(|url| crate::component::untrusted_http_url(url))?)
|
||||
}
|
||||
"run_command" => Self::RunCommand(s("command")?),
|
||||
"suggest_command" => Self::SuggestCommand(s("command")?),
|
||||
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
|
||||
@@ -829,6 +832,28 @@ mod tests {
|
||||
assert_eq!(key, "password");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_url_actions_take_only_web_links() {
|
||||
let open = |url: &str| {
|
||||
let action = Tag::compound([("type", s("minecraft:open_url")), ("url", s(url))]);
|
||||
Action::parse(&action).and_then(|a| a.click(&[], &[]))
|
||||
};
|
||||
assert_eq!(
|
||||
open("https://example.com/a"),
|
||||
Some(Click::OpenUrl("https://example.com/a".into()))
|
||||
);
|
||||
assert!(open("HTTP://example.com").is_some());
|
||||
for url in [
|
||||
"file:///C:/Windows/System32/cmd.exe",
|
||||
"\\\\attacker\\share\\x.exe",
|
||||
"search-ms:query=x&crumb=location:\\\\attacker\\share",
|
||||
"javascript:alert(1)",
|
||||
"/usr/bin/xterm",
|
||||
] {
|
||||
assert_eq!(open(url), None, "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actions_carry_the_input_values() {
|
||||
let d = Dialog::parse(®ister()).unwrap();
|
||||
|
||||
@@ -90,7 +90,7 @@ fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
|
||||
}
|
||||
|
||||
mod fluids;
|
||||
pub use fluids::update_entity_fluids;
|
||||
pub use fluids::{fluid_flow, update_entity_fluids};
|
||||
|
||||
#[cfg(test)]
|
||||
mod drop_motion_tests {
|
||||
@@ -1038,10 +1038,13 @@ pub fn merge_with_neighbours(
|
||||
(j, i)
|
||||
};
|
||||
// ItemEntity.merge(toStack, fromStack, 64).
|
||||
let room = max_stack(&items[to].stack).min(64) - items[to].stack.count;
|
||||
let moved = room.min(items[from].stack.count);
|
||||
items[to].stack.count += moved;
|
||||
items[from].stack.count -= moved;
|
||||
// A component limit above 64 permits an overstacked target. Vanilla's
|
||||
// signed room then moves the excess back into the source stack.
|
||||
let room =
|
||||
i64::from(max_stack(&items[to].stack).min(64)) - i64::from(items[to].stack.count);
|
||||
let moved = room.min(i64::from(items[from].stack.count));
|
||||
items[to].stack.count = (i64::from(items[to].stack.count) + moved) as u32;
|
||||
items[from].stack.count = (i64::from(items[from].stack.count) - moved) as u32;
|
||||
let (delay, from_age, to_age) =
|
||||
(items[from].pickup_delay, age(items, from), age(items, to));
|
||||
let to_item = &mut items[to];
|
||||
@@ -1187,6 +1190,15 @@ impl ExperienceOrb {
|
||||
let sky = light >> 4;
|
||||
(u32::try_from(sky).unwrap_or(0) * 16) | ((u32::try_from(block).unwrap_or(15) * 16) << 8)
|
||||
}
|
||||
/// `ExperienceOrb.setUnderwaterMovement`: buoyancy and drag before moving.
|
||||
fn set_underwater_movement(&mut self) {
|
||||
// Vanilla widens float constants before applying double arithmetic.
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * f64::from(0.99_f32),
|
||||
(self.velocity.y + f64::from(5.0e-4_f32)).min(f64::from(0.06_f32)),
|
||||
self.velocity.z * f64::from(0.99_f32),
|
||||
);
|
||||
}
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
@@ -1216,11 +1228,7 @@ impl ExperienceOrb {
|
||||
// inside blocks takes no gravity until it is pushed out below.
|
||||
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
|
||||
if facts.get(block).fluid == Fluid::Water {
|
||||
self.velocity = Vec3::new(
|
||||
self.velocity.x * 0.99,
|
||||
(self.velocity.y + 5.0e-4).min(0.06),
|
||||
self.velocity.z * 0.99,
|
||||
);
|
||||
self.set_underwater_movement();
|
||||
} else if !colliding {
|
||||
self.velocity.y -= self.get_gravity();
|
||||
}
|
||||
@@ -1285,6 +1293,8 @@ impl ExperienceOrb {
|
||||
if moved.y != wanted.y {
|
||||
self.velocity.y = 0.0;
|
||||
}
|
||||
// ExperienceOrb.tick applies block effects after moving, before drag.
|
||||
let discarded = server && self.apply_lava_contact_damage(world, fluids, wanted);
|
||||
// `float friction = getAirDrag()` (0.98F), the ground's block
|
||||
// friction multiplied in as a float, widened once for the scale.
|
||||
let mut friction = 0.98_f32;
|
||||
@@ -1311,7 +1321,38 @@ impl ExperienceOrb {
|
||||
self.velocity.y = -wanted.y * 0.4;
|
||||
}
|
||||
self.age += 1;
|
||||
self.age < 6000
|
||||
!discarded && self.age < 6000
|
||||
}
|
||||
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
|
||||
/// server, through the orb's existing hurt/discard policy. This is separate
|
||||
/// from the lava impulse at the start of the tick.
|
||||
fn apply_lava_contact_damage(
|
||||
&mut self,
|
||||
world: &World,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
wanted: Vec3,
|
||||
) -> bool {
|
||||
let contacts = orb_lava_contacts(self.previous, self.position, wanted, |p| {
|
||||
let fluid = fluids.fluid(world.block(p.x, p.y, p.z));
|
||||
if !fluid
|
||||
.kind()
|
||||
.same_family(horizon_world::fluid::FluidKind::Lava)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let above = fluids.fluid(world.block(p.x, p.y + 1, p.z));
|
||||
Some(if fluid.kind().same_family(above.kind()) {
|
||||
1.0
|
||||
} else {
|
||||
f64::from(fluid.own_height())
|
||||
})
|
||||
});
|
||||
for _ in 0..contacts {
|
||||
if self.hurt(4.0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `Entity.moveTowardsClosestSpace` from the middle of the orb's box:
|
||||
/// towards the nearest side of its block (north, south, west, east or
|
||||
@@ -2459,6 +2500,35 @@ mod merge_tests {
|
||||
assert_eq!(items[0].age, 8, "age 7, plus the rest of this tick");
|
||||
}
|
||||
|
||||
/// ItemEntity.merge uses signed room, including zero and negative transfers.
|
||||
#[test]
|
||||
fn overstacked_items_transfer_back_without_losing_merge_metadata() {
|
||||
for (counts, expected) in [([80, 10], [64, 26]), ([64, 26], [64, 26])] {
|
||||
let mut items = [
|
||||
item("minecraft:stone", counts[0], 0.5, 41),
|
||||
item("minecraft:stone", counts[1], 0.6, 7),
|
||||
];
|
||||
for item in &mut items {
|
||||
item.stack.data_mut().set_other(
|
||||
"minecraft:max_stack_size",
|
||||
Some(horizon_world::nbt::Tag::Int(99)),
|
||||
);
|
||||
}
|
||||
let before = [items[0].stack.clone(), items[1].stack.clone()];
|
||||
items[1].pickup_delay = 30;
|
||||
let mut removed = [false; 2];
|
||||
let mut cells = ItemCells::new(&items);
|
||||
merge_with_neighbours(&mut items, &mut removed, &mut cells, 0, &|_| 99);
|
||||
assert_eq!([items[0].stack.count, items[1].stack.count], expected);
|
||||
assert_eq!(removed, [false, false]);
|
||||
assert_eq!(items[0].pickup_delay, 30);
|
||||
assert_eq!(items[0].age, 8, "younger age 7, then this tick ends");
|
||||
for (item, stack) in items.iter().zip(&before) {
|
||||
assert!(item.stack.same_item_same_components(stack));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_merge_only_within_half_a_block_sideways() {
|
||||
// Boxes 0.25 wide grown by 0.5: centres closer than 0.75 meet.
|
||||
@@ -3832,6 +3902,72 @@ mod item_destroyed_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod orb_underwater_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_the_executed_java_private_method() {
|
||||
// Actual 26.3 setUnderwaterMovement calls on server-level orbs,
|
||||
// captured by test_orb_water_parity.py (orb-water-reference.json).
|
||||
// Cover both horizontal signs, falling/rising, and the vertical cap.
|
||||
let cases: [([f64; 3], [u64; 3]); 6] = [
|
||||
(
|
||||
[0.0, 0.0, 0.0],
|
||||
[0x0000000000000000, 0x3f40624de0000000, 0x0000000000000000],
|
||||
),
|
||||
(
|
||||
[0.1, 0.01, -0.2],
|
||||
[0x3fb9581066666667, 0x3f85810625ae147b, 0xbfc9581066666667],
|
||||
),
|
||||
(
|
||||
[-0.25, -0.03, 0.125],
|
||||
[0xbfcfae1480000000, 0xbf9e353f7c851eb8, 0x3fbfae1480000000],
|
||||
),
|
||||
(
|
||||
[0.0, 0.059, 0.0],
|
||||
[0x0000000000000000, 0x3fae76c8b46d9168, 0x0000000000000000],
|
||||
),
|
||||
(
|
||||
[0.5, 0.0595, -0.5],
|
||||
[0x3fdfae1480000000, 0x3faeb851e0000000, 0xbfdfae1480000000],
|
||||
),
|
||||
(
|
||||
[-1.0, 0.2, 1.0],
|
||||
[0xbfefae1480000000, 0x3faeb851e0000000, 0x3fefae1480000000],
|
||||
),
|
||||
];
|
||||
let actual: Vec<_> = cases
|
||||
.iter()
|
||||
.map(|(v, _)| {
|
||||
let mut orb = ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
position: Vec3::ZERO,
|
||||
previous: Vec3::ZERO,
|
||||
velocity: Vec3::new(v[0], v[1], v[2]),
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
};
|
||||
orb.set_underwater_movement();
|
||||
[
|
||||
orb.velocity.x.to_bits(),
|
||||
orb.velocity.y.to_bits(),
|
||||
orb.velocity.z.to_bits(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let expected: Vec<_> = cases.into_iter().map(|(_, bits)| bits).collect();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod render_light_tests {
|
||||
use super::*;
|
||||
@@ -3905,3 +4041,186 @@ mod render_light_tests {
|
||||
}
|
||||
}
|
||||
crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
|
||||
#[cfg(test)]
|
||||
mod orb_lava_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_server_orb_takes_lava_damage_after_moving_and_discards_at_zero() {
|
||||
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!("orb lava fixture unavailable: prepared resources missing");
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let lava = assets.registry.default_state("minecraft:lava").unwrap();
|
||||
world.set(20, 4, 20, lava).unwrap();
|
||||
let at = Vec3::new(20.5, 4.1, 20.5);
|
||||
let mut orb = ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
};
|
||||
let mut client = orb.clone();
|
||||
for expected_age in 1..=2 {
|
||||
assert!(client.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
false,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids
|
||||
));
|
||||
assert_eq!((client.health, client.age), (5, expected_age));
|
||||
}
|
||||
assert!(orb.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids
|
||||
));
|
||||
assert_eq!((orb.health, orb.age), (1, 1));
|
||||
assert!(!orb.tick(
|
||||
&world,
|
||||
&facts,
|
||||
&collision,
|
||||
None,
|
||||
true,
|
||||
&mut || 0.0,
|
||||
None,
|
||||
&fluids
|
||||
));
|
||||
assert_eq!((orb.health, orb.age), (-3, 2));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.checkInsideBlocks` and `collidedWithFluid`: fluid AABB contacts
|
||||
/// along Entity.move's axis-ordered path, each block visited once.
|
||||
fn orb_lava_contacts(
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
wanted: Vec3,
|
||||
mut lava_height: impl FnMut(BlockPos) -> Option<f64>,
|
||||
) -> usize {
|
||||
let delta = to.subtract(from);
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
let mut contacts = 0;
|
||||
let mut remaining = 16;
|
||||
let mut segment = |start: Vec3, end: Vec3, budget: i32| {
|
||||
let mut iterations = 0;
|
||||
let epsilon = f64::from(1.0e-5_f32);
|
||||
let target = ExperienceOrb::box_at(end).deflate(Vec3::new(epsilon, epsilon, epsilon));
|
||||
let _ = horizon_core::geometry::sweep_intersected_blocks(
|
||||
start,
|
||||
end,
|
||||
target,
|
||||
1_000_000,
|
||||
|p, step| {
|
||||
if step >= budget {
|
||||
return false;
|
||||
}
|
||||
iterations = step;
|
||||
if visited.contains(&p) {
|
||||
return true;
|
||||
}
|
||||
if let Some(height) = lava_height(p) {
|
||||
let min = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
|
||||
let fluid = Aabb::new(min, min.add_vector(Vec3::new(1.0, height, 1.0)));
|
||||
if ExperienceOrb::box_at(start)
|
||||
.collided_along_vector(end.subtract(start), &[fluid])
|
||||
{
|
||||
visited.insert(p);
|
||||
contacts += 1;
|
||||
}
|
||||
}
|
||||
true
|
||||
},
|
||||
);
|
||||
iterations + 1
|
||||
};
|
||||
if delta.length_squared() > 0.0 {
|
||||
// Direction.axisStepOrder uses the original, uncollided movement.
|
||||
let order = if wanted.x.abs() < wanted.z.abs() {
|
||||
[1, 2, 0]
|
||||
} else {
|
||||
[1, 0, 2]
|
||||
};
|
||||
let components = delta.components();
|
||||
let mut start = from;
|
||||
for axis in order {
|
||||
if components[axis] != 0.0 {
|
||||
let mut end = start.components();
|
||||
end[axis] += components[axis];
|
||||
let end = Vec3::new(end[0], end[1], end[2]);
|
||||
remaining -= segment(start, end, remaining);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
remaining -= segment(from, to, remaining);
|
||||
}
|
||||
if remaining <= 0 {
|
||||
segment(to, to, 1);
|
||||
}
|
||||
contacts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod orb_lava_shape_tests {
|
||||
use super::*;
|
||||
|
||||
fn contacts(from: Vec3, to: Vec3) -> usize {
|
||||
orb_lava_contacts(from, to, to.subtract(from), |p| {
|
||||
(p == BlockPos::new(0, 0, 0)).then_some(f64::from(8.0_f32 / 9.0))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lava_uses_the_swept_box_and_fluid_height_not_the_feet_block() {
|
||||
let side = Vec3::new(-0.2, 0.1, 0.5);
|
||||
assert_eq!(contacts(side, side), 1);
|
||||
let above = Vec3::new(0.5, 0.95, 0.5);
|
||||
assert_eq!(contacts(above, above), 0);
|
||||
let through = Vec3::new(-1.0, 0.1, 0.5);
|
||||
let end = Vec3::new(2.0, 0.1, 0.5);
|
||||
assert_eq!(contacts(through, end), 1);
|
||||
let away = Vec3::new(-1.0, 0.1, -1.0);
|
||||
assert_eq!(contacts(away, away), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_segments_do_not_damage_twice_for_the_same_lava_cell() {
|
||||
let from = Vec3::new(0.1, 0.1, 0.1);
|
||||
let to = Vec3::new(0.7, 0.2, 0.7);
|
||||
assert_eq!(contacts(from, to), 1);
|
||||
assert_eq!(
|
||||
orb_lava_contacts(from, to, Vec3::new(0.1, 0.1, 0.9), |p| {
|
||||
(p == BlockPos::new(0, 0, 0)).then_some(1.0)
|
||||
}),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,25 @@ struct EntityFluidWorld<'a> {
|
||||
world: &'a World,
|
||||
fluids: &'a FluidResources,
|
||||
}
|
||||
/// `FluidState.getFlow(level, pos)`: the current of the fluid at `pos`, the
|
||||
/// one that pushes an item or a boat in it, normalised (zero where there is no
|
||||
/// fluid or it doesn't move: a source with only sources and walls around it).
|
||||
/// A falling fluid's current points down.
|
||||
pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64; 3] {
|
||||
let mut velocity = Vec3::ZERO;
|
||||
let mut scene = EntityFluidWorld {
|
||||
bounds: Aabb::new(Vec3::ZERO, Vec3::ZERO),
|
||||
position: Vec3::ZERO,
|
||||
eye_y: 0.0,
|
||||
velocity: &mut velocity,
|
||||
world,
|
||||
fluids,
|
||||
};
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let state = fluids.fluid(world.block(pos[0], pos[1], pos[2]));
|
||||
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
|
||||
[flow.x, flow.y, flow.z]
|
||||
}
|
||||
/// `Entity.baseTick`'s fluid step for an entity that is pushed by fluid: its
|
||||
/// immersion heights, then the current of each fluid it sits in, added to
|
||||
/// `velocity` with vanilla's scales (water 0.014, lava 0.007 fast or
|
||||
@@ -215,6 +234,45 @@ impl FluidFlowWorld for EntityFluidWorld<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `FluidState.getFlow`: water running out of a pool flows away from it,
|
||||
/// while the pool itself, its edge on a solid floor and the air beside it
|
||||
/// have no current.
|
||||
#[test]
|
||||
fn fluid_flow_runs_away_from_a_pool_and_is_zero_in_still_water() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let mut state = assets.registry.state(water).unwrap().clone();
|
||||
state.properties.insert("level".into(), "1".into());
|
||||
let flowing = assets.registry.resolve(&state).unwrap();
|
||||
for x in 16..=22 {
|
||||
for z in 16..=20 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
for x in 17..=19 {
|
||||
for z in 17..=19 {
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
world.set(20, 4, 18, flowing).unwrap();
|
||||
// A level-1 flow beside sources: the source is 1/9 higher to the west,
|
||||
// the air to the east on stone gives nothing, so the current is +x.
|
||||
assert_eq!(fluid_flow(&world, &fluids, [20, 4, 18]), [1.0, 0.0, 0.0]);
|
||||
for still in [[18, 4, 18], [17, 4, 17], [21, 4, 18]] {
|
||||
assert_eq!(fluid_flow(&world, &fluids, still), [0.0; 3], "{still:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -385,10 +443,12 @@ mod tests {
|
||||
None,
|
||||
&fluids,
|
||||
));
|
||||
// Current pushes the orb, underwater drag damps before motion, then air
|
||||
// drag: `ExperienceOrb.tick`'s `0.98F`, a float widened to double.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - 0.014 * 0.99 * f64::from(0.98_f32)).abs() < 1e-12);
|
||||
// Current pushes the orb, then underwater and air drag each widen
|
||||
// their float constants before multiplying the double motion.
|
||||
assert!((carried.position.x - at.x - 0.014 * f64::from(0.99_f32)).abs() < 1e-12);
|
||||
assert!(
|
||||
(carried.velocity.x - 0.014 * f64::from(0.99_f32) * f64::from(0.98_f32)).abs() < 1e-12
|
||||
);
|
||||
for _ in 0..20 {
|
||||
carried.tick(
|
||||
&world,
|
||||
|
||||
@@ -370,6 +370,18 @@ pub fn mob_to_nbt(
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
// Mob.addAdditionalSaveData: hasHome means radius != -1, including -2.
|
||||
// Species writers below retain their own home_pos override.
|
||||
let Tag::Compound(map) = &mut tag else {
|
||||
unreachable!()
|
||||
};
|
||||
if let Some((pos, radius)) = m.ai.home.filter(|(_, radius)| *radius != -1) {
|
||||
map.insert("home_radius".into(), Tag::Int(radius));
|
||||
map.insert("home_pos".into(), Tag::IntArray(pos.to_vec()));
|
||||
} else {
|
||||
map.remove(&"home_radius".into());
|
||||
map.remove(&"home_pos".into());
|
||||
}
|
||||
// AbstractFish/Axolotl.addAdditionalSaveData always write the flag.
|
||||
// Tadpole.fromBucket is always true, and its setter ignores loaded input.
|
||||
let from_bucket = match m.species() {
|
||||
@@ -692,6 +704,14 @@ pub fn mob_to_nbt(
|
||||
Tag::Byte(i8::from(m.extra.flag2)),
|
||||
);
|
||||
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
|
||||
if id == "minecraft:hoglin" {
|
||||
// Hoglin.addAdditionalSaveData writes it only when set.
|
||||
if m.extra.cannot_be_hunted {
|
||||
map.insert("CannotBeHunted".into(), Tag::Byte(1));
|
||||
} else {
|
||||
map.remove(&"CannotBeHunted".into());
|
||||
}
|
||||
}
|
||||
if id == "minecraft:piglin" {
|
||||
// InventoryCarrier.writeInventoryToTag.
|
||||
map.insert(
|
||||
@@ -1422,6 +1442,44 @@ pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
|
||||
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
|
||||
float(bonus.get("amount"))
|
||||
}
|
||||
/// `Mob.readAdditionalSaveData`: negative radii retain the live homePosition.
|
||||
/// Used after the command adapter restores runtime AI, as well as fresh load.
|
||||
pub fn read_mob_home(m: &mut Mob, tag: &Tag) {
|
||||
let radius = match tag.get("home_radius") {
|
||||
Some(Tag::Float(v)) => Some(f32::from_bits(*v).floor() as i32),
|
||||
Some(Tag::Double(v)) => Some(f64::from_bits(*v).floor() as i32),
|
||||
other => number(other).map(|v| v as i32),
|
||||
}
|
||||
.unwrap_or(-1);
|
||||
let pos = if radius >= 0 {
|
||||
home_block_pos(tag.get("home_pos")).unwrap_or([0; 3])
|
||||
} else {
|
||||
m.ai.home_position()
|
||||
};
|
||||
m.ai.restore_home(pos, radius);
|
||||
}
|
||||
/// `BlockPos.CODEC` through NbtOps.getIntStream: numeric collection entries
|
||||
/// use Java Number's int conversion (floating entries truncate, unlike getIntOr above).
|
||||
fn home_block_pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
|
||||
match tag? {
|
||||
Tag::IntArray(v) => v.as_slice().try_into().ok(),
|
||||
Tag::ByteArray(v) if v.len() == 3 => Some([
|
||||
i32::from(v[0] as i8),
|
||||
i32::from(v[1] as i8),
|
||||
i32::from(v[2] as i8),
|
||||
]),
|
||||
Tag::LongArray(v) if v.len() == 3 => Some([v[0] as i32, v[1] as i32, v[2] as i32]),
|
||||
Tag::List(v) if v.len() == 3 => {
|
||||
let int = |t: &Tag| match t {
|
||||
Tag::Float(v) => Some(f32::from_bits(*v) as i32),
|
||||
Tag::Double(v) => Some(f64::from_bits(*v) as i32),
|
||||
other => number(Some(other)).map(|v| v as i32),
|
||||
};
|
||||
Some([int(&v[0])?, int(&v[1])?, int(&v[2])?])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The mob a saved compound describes. `registry` is what reads a field holding
|
||||
/// a block state (an enderman's `carriedBlockState`); without one, such a field
|
||||
/// is left out, as it was before it was modelled.
|
||||
@@ -1437,6 +1495,7 @@ pub fn mob_from_nbt(
|
||||
let mut m = Mob::new(id, kind, position, yaw);
|
||||
m.velocity = motion(tag);
|
||||
m.head_pitch = pitch;
|
||||
read_mob_home(&mut m, tag);
|
||||
m.on_ground = flag(tag, "OnGround");
|
||||
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
// LivingEntity.readAdditionalSaveData: the saved attribute bases, then
|
||||
@@ -1687,6 +1746,9 @@ pub fn mob_from_nbt(
|
||||
) {
|
||||
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
|
||||
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
|
||||
if m.species() == crate::mobs::Species::Hoglin {
|
||||
m.extra.cannot_be_hunted = flag(tag, "CannotBeHunted");
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Piglin
|
||||
&& let Some(Tag::List(items)) = tag.get("Inventory")
|
||||
{
|
||||
@@ -5090,6 +5152,42 @@ mod sheep_nbt_tests {
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
/// `Hoglin.readAdditionalSaveData` and `addAdditionalSaveData`:
|
||||
/// `CannotBeHunted` (a bastion's hoglins) stays with the hoglin, and such a
|
||||
/// hoglin is no piglin's prey (`Hoglin.canBeHunted`).
|
||||
#[test]
|
||||
fn a_hoglin_keeps_cannot_be_hunted_and_is_no_prey() {
|
||||
use crate::mobs::Species;
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.species = Species::Hoglin;
|
||||
ty.id = "minecraft:hoglin".into();
|
||||
let types = HashMap::from([("minecraft:hoglin".to_owned(), kind)]);
|
||||
let hoglin = |cannot: Option<i8>| {
|
||||
let mut tag = Tag::compound([
|
||||
("id", string("minecraft:hoglin")),
|
||||
("Pos", doubles(&[0.0, 64.0, 0.0])),
|
||||
]);
|
||||
if let (Some(b), Tag::Compound(map)) = (cannot, &mut tag) {
|
||||
map.insert("CannotBeHunted".into(), Tag::Byte(b));
|
||||
}
|
||||
tag
|
||||
};
|
||||
let raw = hoglin(Some(1));
|
||||
let protected = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
assert!(protected.extra.cannot_be_hunted);
|
||||
assert!(!protected.seen(None).huntable);
|
||||
let saved = mob_to_nbt(&protected, [0; 4], Some(&raw), None);
|
||||
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(1)));
|
||||
for raw in [hoglin(None), hoglin(Some(0))] {
|
||||
let prey = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
assert!(!prey.extra.cannot_be_hunted);
|
||||
assert!(prey.seen(None).huntable);
|
||||
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None);
|
||||
assert_eq!(saved.get("CannotBeHunted"), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mob.addAdditionalSaveData` and `readAdditionalSaveData`:
|
||||
/// `DeathLootTable` and a set `DeathLootTableSeed` stay with the mob.
|
||||
#[test]
|
||||
@@ -6602,3 +6700,145 @@ mod axolotl_memory_ttl_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod generic_home_nbt_tests {
|
||||
use super::*;
|
||||
use crate::mobs::Species;
|
||||
|
||||
fn mob(species: Species, id: &str) -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.species = species;
|
||||
ty.id = id.into();
|
||||
Mob::new(1, kind, Vec3::new(3.5, 201.0, 0.5), 0.0)
|
||||
}
|
||||
fn stale() -> Tag {
|
||||
Tag::compound([
|
||||
("home_radius", Tag::Int(99)),
|
||||
("home_pos", Tag::IntArray(vec![90, 91, 92])),
|
||||
("audit:unrelated", Tag::Int(7)),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_home_output_uses_model_and_removes_stale_raw_fields() {
|
||||
let mut m = mob(Species::Pig, "minecraft:pig");
|
||||
let raw = stale();
|
||||
for raw in [None, Some(&raw)] {
|
||||
for radius in [0, 5, -2, -1] {
|
||||
m.ai.home = Some(([4, 5, 6], radius));
|
||||
let saved = mob_to_nbt(&m, [0; 4], raw, None);
|
||||
if radius == -1 {
|
||||
assert!(saved.get("home_radius").is_none());
|
||||
assert!(saved.get("home_pos").is_none());
|
||||
} else {
|
||||
assert_eq!(saved.get("home_radius"), Some(&Tag::Int(radius)));
|
||||
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 5, 6])));
|
||||
}
|
||||
}
|
||||
m.ai.home = None;
|
||||
let saved = mob_to_nbt(&m, [0; 4], raw, None);
|
||||
assert!(saved.get("home_radius").is_none());
|
||||
assert!(saved.get("home_pos").is_none());
|
||||
if raw.is_some() {
|
||||
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_home_updates_retain_position_without_replacing_runtime_ai() {
|
||||
let mut m = mob(Species::Pig, "minecraft:pig");
|
||||
m.ai.home = Some(([4, 5, 6], 5));
|
||||
m.ai.jump = true;
|
||||
m.ai.speed = 0.37;
|
||||
m.ai.no_action_time = 29;
|
||||
for radius in [-2, -1, -2] {
|
||||
read_mob_home(&mut m, &Tag::compound([("home_radius", Tag::Int(radius))]));
|
||||
assert_eq!(m.ai.home, (radius != -1).then_some(([4, 5, 6], radius)));
|
||||
assert_eq!(m.ai.home_position(), [4, 5, 6]);
|
||||
assert!(m.ai.jump);
|
||||
assert_eq!((m.ai.speed, m.ai.no_action_time), (0.37, 29));
|
||||
}
|
||||
// Runtime setters still assign ai.home directly. clearHome must retain
|
||||
// that latest live position, rather than a previous load's position.
|
||||
m.ai.home = Some(([7, 8, 9], 5));
|
||||
m.ai.clear_home();
|
||||
assert!(m.ai.home.is_none());
|
||||
read_mob_home(&mut m, &Tag::compound([("home_radius", Tag::Int(-2))]));
|
||||
assert_eq!(m.ai.home, Some(([7, 8, 9], -2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_home_loads_apply_radius_and_position_codec_defaults() {
|
||||
let m = mob(Species::Pig, "minecraft:pig");
|
||||
let types = [(m.kind.id.clone(), m.kind.clone())].into();
|
||||
for radius in [0, 5, -1, -2] {
|
||||
for pos in [
|
||||
None,
|
||||
Some(Tag::IntArray(vec![4, 5, 6])),
|
||||
Some(Tag::IntArray(vec![4, 5])),
|
||||
] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string("minecraft:pig")),
|
||||
("Pos", doubles(&[3.5, 201.0, 0.5])),
|
||||
("home_radius", Tag::Int(radius)),
|
||||
]);
|
||||
if let Some(pos) = pos.clone() {
|
||||
put(&mut raw, "home_pos", pos);
|
||||
}
|
||||
let loaded = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
let expected_pos = if radius >= 0 && pos == Some(Tag::IntArray(vec![4, 5, 6])) {
|
||||
[4, 5, 6]
|
||||
} else {
|
||||
[0; 3]
|
||||
};
|
||||
assert_eq!(
|
||||
loaded.ai.home,
|
||||
(radius != -1).then_some((expected_pos, radius))
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut loaded = m;
|
||||
read_mob_home(
|
||||
&mut loaded,
|
||||
&Tag::compound([
|
||||
("home_radius", Tag::Float((-2.5_f32).to_bits())),
|
||||
("home_pos", Tag::IntArray(vec![4, 5, 6])),
|
||||
]),
|
||||
);
|
||||
assert_eq!(loaded.ai.home, Some(([0; 3], -3)));
|
||||
read_mob_home(
|
||||
&mut loaded,
|
||||
&Tag::compound([
|
||||
("home_radius", Tag::Int(5)),
|
||||
("home_pos", doubles(&[-2.5, 5.5, 6.5])),
|
||||
]),
|
||||
);
|
||||
assert_eq!(loaded.ai.home, Some(([-2, 5, 6], 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turtle_and_creaking_keep_their_own_home_position_writer() {
|
||||
let raw = stale();
|
||||
for (species, id) in [
|
||||
(Species::Turtle, "minecraft:turtle"),
|
||||
(Species::Creaking, "minecraft:creaking"),
|
||||
] {
|
||||
let mut m = mob(species, id);
|
||||
m.extra.home = Some([4, 5, 6]);
|
||||
for raw in [None, Some(&raw)] {
|
||||
for home in [None, Some(([7, 8, 9], 5))] {
|
||||
m.ai.home = home;
|
||||
let saved = mob_to_nbt(&m, [0; 4], raw, None);
|
||||
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 5, 6])));
|
||||
assert_eq!(
|
||||
saved.get("home_radius"),
|
||||
home.map(|(_, r)| Tag::Int(r)).as_ref()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ mod enchant_effects;
|
||||
mod end;
|
||||
mod entity;
|
||||
mod entity_culling;
|
||||
mod entity_sounds;
|
||||
mod event_loop;
|
||||
mod events;
|
||||
mod execution_role;
|
||||
@@ -206,6 +207,7 @@ pub mod native_block_entity_draw;
|
||||
mod native_block_sprites;
|
||||
mod native_chunk_tickets;
|
||||
mod native_claimed_items;
|
||||
pub mod native_client_services;
|
||||
mod native_components;
|
||||
mod native_configs;
|
||||
mod native_criteria;
|
||||
@@ -226,6 +228,7 @@ mod native_level_refs;
|
||||
mod native_lifecycle;
|
||||
mod native_living_refs;
|
||||
mod native_machines;
|
||||
mod native_menu_stacks;
|
||||
mod native_menus;
|
||||
mod native_mob_draw;
|
||||
mod native_mobs;
|
||||
@@ -238,6 +241,7 @@ mod native_projectiles;
|
||||
mod native_quick_move;
|
||||
mod native_recipes;
|
||||
mod native_reload_listeners;
|
||||
mod native_scene;
|
||||
mod native_server;
|
||||
mod native_shaders;
|
||||
mod native_ticks;
|
||||
@@ -304,6 +308,7 @@ mod server_links;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod sheep_dye;
|
||||
mod shelf;
|
||||
mod shield;
|
||||
mod shoulders;
|
||||
@@ -355,6 +360,7 @@ mod win;
|
||||
mod wind;
|
||||
mod wither;
|
||||
mod world_loading;
|
||||
mod world_primitives;
|
||||
mod zombie_reinforcements;
|
||||
mod zombie_villagers;
|
||||
use event_loop::EventLoopCtl;
|
||||
@@ -419,6 +425,7 @@ use horizon_world::{
|
||||
},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
pub use machines::native_fluids::NativeFluidTank;
|
||||
pub use maps::{NativeMapDecoration, NativeMapInfo};
|
||||
pub use native_block_entity_draw::BlockEntityDraw;
|
||||
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
|
||||
@@ -427,7 +434,7 @@ pub use native_configs::{NativeConfig, NativeConfigFile, NativeConfigKind};
|
||||
pub use native_criteria::NativeCriterion;
|
||||
pub use native_data_maps::NativeDataMapType;
|
||||
pub use native_falling::NativeFalling;
|
||||
pub use native_hooks::{NativeClock, NativeItemEntity, NativeShake};
|
||||
pub use native_hooks::{NativeClock, NativeItemEntity, NativePlayer, NativeShake};
|
||||
pub use native_item_energy::ItemEnergyBuffer;
|
||||
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_reload_listeners::NativeReloadListener;
|
||||
@@ -2371,6 +2378,9 @@ struct Game<'a> {
|
||||
world_renderer_ready: bool,
|
||||
collision: &'a BlockCollisionRegistry<'a>,
|
||||
motion: &'a MotionResources,
|
||||
/// This world's frozen damage definitions and reloadable tag snapshot.
|
||||
pub(super) health_resources: horizon_client::health::HealthResources,
|
||||
damage_tag_generation: u64,
|
||||
/// This game's resource packs (`reload_packs` replaces them).
|
||||
pack: PackResources,
|
||||
/// The title and menu bundle (GUI atlas, font and panorama).
|
||||
@@ -2891,6 +2901,9 @@ struct Game<'a> {
|
||||
cart_models: minecarts::CartModels,
|
||||
/// The boat the player rides.
|
||||
riding: Option<u32>,
|
||||
/// Vehicles that gained a rider this tick whose `START_RIDING_TRIGGER` was
|
||||
/// not fired yet (`game/boarding.rs`), for a guest's turn.
|
||||
started_riding: Vec<u32>,
|
||||
/// `Entity.boardingCooldown` of the acting player, and its vehicle at the
|
||||
/// last tick (`game/boarding.rs`).
|
||||
boarding_cooldown: i32,
|
||||
@@ -3076,6 +3089,7 @@ struct Game<'a> {
|
||||
text_selection: horizon_client::clipboard::AppendSelection,
|
||||
/// The world was saved for the application ending (`save_for_exit`).
|
||||
saved_for_exit: bool,
|
||||
native_client_stopped: bool,
|
||||
pending_save: Option<saving::ClosingSave>,
|
||||
/// The last world frame reached the swap chain. A window the compositor
|
||||
/// stops showing gets no frames, and the event loop must not spin on it.
|
||||
@@ -4356,9 +4370,7 @@ impl Game<'_> {
|
||||
&& (alive || item.age == 6000 && item.position.y > -128.0)
|
||||
&& item.ticks.is_multiple_of(rate)
|
||||
{
|
||||
let catalog = world_items!(self);
|
||||
let max_stack =
|
||||
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
|
||||
let max_stack = |stack: &ItemStack| self.stack_max_size(stack).max(1);
|
||||
horizon_client::entities::merge_with_neighbours(
|
||||
&mut items,
|
||||
&mut removed,
|
||||
@@ -5178,15 +5190,19 @@ impl Game<'_> {
|
||||
let sent = self.lan_broadcast_changes();
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.run_block_events(level, ticks);
|
||||
b.tick_block_entities(level, ticks);
|
||||
});
|
||||
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
|
||||
// with the progress it is about to take.
|
||||
self.process_level_events()?;
|
||||
self.sync_moving_pistons();
|
||||
Ok(sent)
|
||||
}
|
||||
/// `ServerLevel.tick`: moving block entities push after entity travel.
|
||||
fn tick_moving_pistons(&mut self) -> Result<()> {
|
||||
self.with_level(|b, level, ticks| b.tick_block_entities(level, ticks));
|
||||
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
|
||||
self.push_piston_entities(pushes);
|
||||
self.process_level_events()?;
|
||||
self.sync_moving_pistons();
|
||||
Ok(sent)
|
||||
Ok(())
|
||||
}
|
||||
/// Copies the moving-piston block entities into the world, where the
|
||||
/// collision queries and the renderer read them.
|
||||
@@ -7072,12 +7088,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
self.start_using_item();
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
|
||||
self.play_player_bound_sound(&instrument.sound, 2, instrument.range / 16.0, 1.0, true);
|
||||
self.post_entity_event(
|
||||
"minecraft:instrument_play",
|
||||
horizon_client::mobs::Who::Player,
|
||||
@@ -7890,6 +7901,7 @@ impl Game<'_> {
|
||||
self.apply_late_wind_pushes();
|
||||
}
|
||||
if running {
|
||||
horizon_client::spans::time("tick_moving_pistons", || self.tick_moving_pistons())?;
|
||||
horizon_client::spans::time("tick_block_entities", || self.tick_block_entities())?;
|
||||
horizon_client::spans::time("tick_sculk_entities", || self.tick_sculk_entities());
|
||||
horizon_client::spans::time("tick_sculk_blocks", || self.tick_sculk_blocks())?;
|
||||
@@ -8593,11 +8605,11 @@ impl Game<'_> {
|
||||
self.frame_graph
|
||||
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
|
||||
self.draw_pad_cursor(&mut batch);
|
||||
self.resolve_inline_profiles(&batch.inline_profiles);
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
let mut batch = HudBatch::new(size);
|
||||
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
|
||||
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
|
||||
batch.game_time = ((self.world.game_time.rem_euclid(24000)) as f32
|
||||
+ self.clock.fraction() as f32)
|
||||
@@ -8749,7 +8761,10 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
self.gui_layer("minecraft:effects", &mut batch, |g, batch| {
|
||||
g.draw_effect_icons(batch);
|
||||
// The PvP & HUD effects widget takes the icons' place.
|
||||
if !g.pvp_effects_shown() {
|
||||
g.draw_effect_icons(batch);
|
||||
}
|
||||
});
|
||||
// Hud: the action bar and titles draw over the hotbar (nextStratum).
|
||||
self.gui_layer("minecraft:boss_overlay", &mut batch, |g, batch| {
|
||||
@@ -11693,6 +11708,25 @@ impl Game<'_> {
|
||||
"block/destroy_stage_8",
|
||||
"block/destroy_stage_9",
|
||||
];
|
||||
// `ModelBakery.DESTROY_STAGES_OIT`: with improved transparency the cracks are
|
||||
// ordinary translucent quads (`OIT_CRUMBLING`) and use their own sprites.
|
||||
const DESTROY_STAGES_OIT: [&str; 10] = [
|
||||
"block/destroy_oit_stage_0",
|
||||
"block/destroy_oit_stage_1",
|
||||
"block/destroy_oit_stage_2",
|
||||
"block/destroy_oit_stage_3",
|
||||
"block/destroy_oit_stage_4",
|
||||
"block/destroy_oit_stage_5",
|
||||
"block/destroy_oit_stage_6",
|
||||
"block/destroy_oit_stage_7",
|
||||
"block/destroy_oit_stage_8",
|
||||
"block/destroy_oit_stage_9",
|
||||
];
|
||||
let names = if self.settings.improved_transparency {
|
||||
&DESTROY_STAGES_OIT
|
||||
} else {
|
||||
&DESTROY_STAGES
|
||||
};
|
||||
let mut stages: Vec<([i32; 3], u32)> = Vec::new();
|
||||
let mut add = |pos: [i32; 3], stage: u32| match stages.iter_mut().find(|c| c.0 == pos) {
|
||||
Some(c) => c.1 = c.1.max(stage),
|
||||
@@ -11718,7 +11752,7 @@ impl Game<'_> {
|
||||
d.length_squared() <= 1024.0
|
||||
})
|
||||
.filter_map(|(pos, stage)| {
|
||||
let name = DESTROY_STAGES.get(stage as usize)?;
|
||||
let name = names.get(stage as usize)?;
|
||||
let r = self.gui.atlas.sprites.get(*name)?;
|
||||
Some((pos, [r.u0, r.v0]))
|
||||
})
|
||||
@@ -12274,7 +12308,7 @@ impl Game<'_> {
|
||||
"first_quarter",
|
||||
"waxing_gibbous",
|
||||
];
|
||||
let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
|
||||
let phase = phases[(self.view_day_time() / 24000).rem_euclid(8) as usize];
|
||||
body(
|
||||
&format!("environment/celestial/moon/{phase}"),
|
||||
sky.moon_angle,
|
||||
@@ -12459,16 +12493,19 @@ impl Game<'_> {
|
||||
self.share_spawn_conditions();
|
||||
self.score = Default::default();
|
||||
self.fns = Default::default();
|
||||
self.world_tables = self.build_world_tables();
|
||||
(self.world_tables, self.health_resources) =
|
||||
self.build_world_tables(false, &level.enabled_features)?;
|
||||
self.damage_tag_generation = self.damage_tag_generation.wrapping_add(1);
|
||||
// Native tag reads use the lock to tell that the tag tables are ready.
|
||||
self.session_lock = Some(session_lock);
|
||||
// The registries a preload or an earlier world read are not this
|
||||
// world's packs': a world with packs sends its own (their dialogs,
|
||||
// recipes and advancements). For a world without packs, `sync_data`
|
||||
// drops what was read with packs.
|
||||
if self.world_tables.is_some() {
|
||||
lan::forget_sync_data();
|
||||
}
|
||||
// Keep the no-pack preload; a world's packs replace it atomically.
|
||||
// A later /reload keeps this session's synchronized registries.
|
||||
lan::open_world_sync_data(
|
||||
&self.options.resources,
|
||||
&pack_files(&self.world_tables),
|
||||
&self.health_resources.damage,
|
||||
)
|
||||
.map_err(|e| format!("world registries: {e}"))?;
|
||||
// Warm the loot data with this world's packs before chunks decode
|
||||
// (`save::roll_loaded_loot` reads the cached copy).
|
||||
let _ = horizon_client::structure_blocks::world_loot_data(
|
||||
@@ -12757,6 +12794,10 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
pub(super) fn save_for_exit(&mut self) -> Result<()> {
|
||||
if self.native_window() && !self.native_client_stopped {
|
||||
self.native_client_stopped = true;
|
||||
self.native_client_stopping();
|
||||
}
|
||||
// Native application termination can bypass CloseRequested. Finish any
|
||||
// save screen synchronously here, before the process can go away.
|
||||
while self.pending_save.is_some() {
|
||||
@@ -12964,15 +13005,13 @@ impl Game<'_> {
|
||||
}
|
||||
let amount = ((damage / 4.0) as u32).max(1);
|
||||
let name = self
|
||||
.motion
|
||||
.health
|
||||
.health_resources
|
||||
.damage
|
||||
.definition(source)
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_default();
|
||||
let fire = self
|
||||
.motion
|
||||
.health
|
||||
.health_resources
|
||||
.damage
|
||||
.in_tag(source, "minecraft:is_fire");
|
||||
let mut broke = false;
|
||||
@@ -13616,9 +13655,14 @@ impl Game<'_> {
|
||||
let want = sound.trim_start_matches("minecraft:");
|
||||
let mut keep = Vec::with_capacity(capture.len());
|
||||
for entry in capture.drain(..) {
|
||||
let name = entry.0.trim_start_matches("minecraft:");
|
||||
let name = entry.event.trim_start_matches("minecraft:");
|
||||
if name == want || name == "block.fire.extinguish" {
|
||||
self.guest_player_sounds.push(entry);
|
||||
self.guest_player_sounds.push((
|
||||
entry.event,
|
||||
entry.position,
|
||||
entry.volume,
|
||||
entry.pitch,
|
||||
));
|
||||
} else {
|
||||
keep.push(entry);
|
||||
}
|
||||
@@ -14410,8 +14454,13 @@ impl Game<'_> {
|
||||
}
|
||||
_ => ItemPose::Swing,
|
||||
};
|
||||
let m = to_world
|
||||
let mut m = to_world
|
||||
* horizon_client::first_person::item_pose(left, inverse_height, attack, pose);
|
||||
// PvP & HUD's shield size (100% is vanilla's), toward the hand holding it.
|
||||
let shield_size = self.settings.extras.pvp.tweaks.shield_size;
|
||||
if shield_size != 100 && &*stack.item == "minecraft:shield" {
|
||||
m *= glam::Mat4::from_scale(glam::Vec3::splat(f32::from(shield_size) / 100.0));
|
||||
}
|
||||
// Seeded by the player id plus the context ordinal.
|
||||
let (context, seed) = if left {
|
||||
(
|
||||
@@ -14633,7 +14682,7 @@ impl Game<'_> {
|
||||
alpha: f64,
|
||||
) -> Vec<horizon_client::weather::Vertex> {
|
||||
use horizon_client::weather;
|
||||
let (intensity, _) = self.weather_levels(alpha as f32);
|
||||
let (intensity, _) = self.view_weather(alpha as f32);
|
||||
if intensity <= 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -15961,8 +16010,8 @@ impl Game<'_> {
|
||||
);
|
||||
// visual/water_fog_color: no dimension sets it, its default is #050533.
|
||||
let water_fog = blend(|b| Some(b.water_fog), [0.0; 3]);
|
||||
let mut sky = environment.sample(self.world.day_time, Some(biome_sky), Some(biome_fog));
|
||||
let (rain, thunder) = self.weather_levels(alpha as f32);
|
||||
let mut sky = environment.sample(self.view_day_time(), Some(biome_sky), Some(biome_fog));
|
||||
let (rain, thunder) = self.view_weather(alpha as f32);
|
||||
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
|
||||
self.end_flash_sky(&mut sky, alpha);
|
||||
if self.flash > 0 && !self.settings.file.bool("hideLightningFlashes") {
|
||||
@@ -15997,6 +16046,11 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let view = horizon_client::camera::view_matrix(view_yaw, view_pitch);
|
||||
let world_primitives = if self.title.is_none() {
|
||||
self.world_primitive_geometry(camera, alpha, view)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let at = camera.to_array();
|
||||
// GameRenderer.render3dHud: the debug crosshair, first person with the HUD shown.
|
||||
let mut crosshair = horizon_client::gizmos::Gizmos::default();
|
||||
@@ -16076,8 +16130,6 @@ impl Game<'_> {
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
self.flush_ime(&mut hud);
|
||||
let title = self.shows_panorama();
|
||||
let (yaw, pitch) = if title {
|
||||
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
|
||||
@@ -16096,8 +16148,6 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
self.locator_camera =
|
||||
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
|
||||
let bob = if title {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -16108,7 +16158,6 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.spinning_matrix(camera_alpha as f32)
|
||||
};
|
||||
let requested_post_effects = self.requested_post_effects();
|
||||
let terrain_distance = self.terrain_distance();
|
||||
let distance = (terrain_distance * 16) as f32;
|
||||
let depth_far =
|
||||
@@ -16139,6 +16188,14 @@ impl Game<'_> {
|
||||
dimension: self.dim.id().to_string(),
|
||||
}
|
||||
});
|
||||
// GUI hooks project against this frame's camera, including its bob/spin and FOV.
|
||||
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
|
||||
self.flush_ime(&mut hud);
|
||||
self.native_gui_render_post(&mut hud, self.ui.hide_gui, self.focused);
|
||||
self.resolve_inline_profiles(&hud.inline_profiles);
|
||||
self.locator_camera =
|
||||
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
|
||||
let requested_post_effects = self.requested_post_effects();
|
||||
self.view_frustum = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -16220,6 +16277,7 @@ impl Game<'_> {
|
||||
if !title {
|
||||
self.end_geometry(camera, &mut entities);
|
||||
}
|
||||
self.outline_fill(&outline, camera, &mut entities);
|
||||
let weather = if !title && self.can_have_weather() && self.settings.horizon.weather {
|
||||
horizon_client::spans::time("weather", || self.weather_geometry(camera, alpha))
|
||||
} else {
|
||||
@@ -16307,6 +16365,7 @@ impl Game<'_> {
|
||||
outline,
|
||||
outline_after_terrain,
|
||||
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
|
||||
pvp: self.settings.extras.pvp.frame(),
|
||||
debug_lines: &debug_lines,
|
||||
debug_lines_on_top: &debug_lines_on_top,
|
||||
debug_crosshair: &debug_crosshair,
|
||||
@@ -16345,6 +16404,7 @@ impl Game<'_> {
|
||||
},
|
||||
additive: &additive,
|
||||
see_through: &see_through,
|
||||
world_primitives: world_primitives.batch(),
|
||||
shadow_only: &shadow_only,
|
||||
celestial: if self.settings.horizon.sky || title {
|
||||
&celestial
|
||||
@@ -16508,7 +16568,9 @@ impl Game<'_> {
|
||||
|| self.dialog_open()
|
||||
|| (self.screen == Screen::Win && self.win_screen_pauses())));
|
||||
let radius = self.settings.menu_blur as f32;
|
||||
(blurred && radius >= 1.0).then_some(radius)
|
||||
let mut radius = (blurred && radius >= 1.0).then_some(radius);
|
||||
self.native_menu_background(&mut radius);
|
||||
radius.filter(|r| r.is_finite() && (1.0..=64.0).contains(r))
|
||||
}
|
||||
/// `LevelExtractor` / `LevelRenderer`: the world border wall this frame.
|
||||
fn border_draw(
|
||||
@@ -18943,6 +19005,14 @@ impl Game<'_> {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
// A Realm's address came back (GetServerDetailsTask hands over to ConnectScreen).
|
||||
if let Some(action) = self.title.as_mut().and_then(|t| t.take_connect_request())
|
||||
&& let Err(error) = self.title_action(event_loop, action)
|
||||
{
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
horizon_client::spans::time("server_packs", || self.tick_server_packs());
|
||||
horizon_client::spans::time("control", || self.poll_control(event_loop));
|
||||
if self.pending_save.is_some() {
|
||||
@@ -19051,7 +19121,7 @@ impl<'a> Game<'a> {
|
||||
&gui.atlas.sprites,
|
||||
&gui.atlas.translucent,
|
||||
);
|
||||
Ok(Game {
|
||||
let mut game = Game {
|
||||
world_biomes: None,
|
||||
world_biomes_base: None,
|
||||
res: res.clone(),
|
||||
@@ -19070,6 +19140,8 @@ impl<'a> Game<'a> {
|
||||
world_renderer_ready: false,
|
||||
collision: res.collision(),
|
||||
motion: res.motion(),
|
||||
health_resources: res.motion().health.clone(),
|
||||
damage_tag_generation: 1,
|
||||
pack,
|
||||
gui,
|
||||
fog: Default::default(),
|
||||
@@ -19380,6 +19452,7 @@ impl<'a> Game<'a> {
|
||||
trim_slots: Vec::new(),
|
||||
cart_models: prepared.cart_models,
|
||||
riding: None,
|
||||
started_riding: Vec::new(),
|
||||
boarding_cooldown: 0,
|
||||
riding_at_tick: None,
|
||||
rotation_forced: false,
|
||||
@@ -19481,6 +19554,7 @@ impl<'a> Game<'a> {
|
||||
clipboard: Default::default(),
|
||||
text_selection: Default::default(),
|
||||
saved_for_exit: false,
|
||||
native_client_stopped: false,
|
||||
pending_save: None,
|
||||
frame_drawn: true,
|
||||
world_icon: None,
|
||||
@@ -19552,7 +19626,11 @@ impl<'a> Game<'a> {
|
||||
camera_type: Default::default(),
|
||||
fangs: Default::default(),
|
||||
player_model: None,
|
||||
})
|
||||
};
|
||||
if game.native_window() {
|
||||
game.native_client_start();
|
||||
}
|
||||
Ok(game)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19893,7 +19971,11 @@ impl Game<'_> {
|
||||
/// Load the world's data packs' content tables (`None`: the world has no
|
||||
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
|
||||
/// so it runs once per world open and on `/reload`.
|
||||
fn build_world_tables(&self) -> Option<WorldTables> {
|
||||
fn build_world_tables(
|
||||
&self,
|
||||
reload: bool,
|
||||
enabled_features: &[String],
|
||||
) -> Result<(Option<WorldTables>, horizon_client::health::HealthResources)> {
|
||||
// The ask of this load, both here and on `/reload`: a native mod
|
||||
// recomputes the pack files it contributes every time.
|
||||
let mut contributed = Vec::new();
|
||||
@@ -19902,31 +19984,27 @@ impl Game<'_> {
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
enabled_features,
|
||||
contributed,
|
||||
);
|
||||
if files.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
let root = &self.options.resources;
|
||||
let items = match Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay) {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's data packs are not applied: {e}");
|
||||
return None;
|
||||
}
|
||||
let base = if reload {
|
||||
&self.health_resources
|
||||
} else {
|
||||
&self.motion.health
|
||||
};
|
||||
let health = base.with_packs(root, &overlay, reload)?;
|
||||
if files.is_empty() {
|
||||
return Ok((None, health));
|
||||
}
|
||||
let mut items = Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay)?;
|
||||
// Item damage predicates use the same frozen registry as player hurt.
|
||||
Arc::make_mut(&mut items.enchantments).bind_damage_catalog(&health.damage);
|
||||
// The recipe tables, the stations and the recipe book read one folder:
|
||||
// hold its files so it is read once.
|
||||
let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe"));
|
||||
let recipes = match Recipes::load(root, &items, &overlay) {
|
||||
Ok(recipes) => recipes,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's data packs are not applied: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let recipes = Recipes::load(root, &items, &overlay)?;
|
||||
let stations = horizon_client::stations::StationData::load(root, &items, &overlay);
|
||||
let mut data_map_types = Vec::new();
|
||||
self.native_data_map_types(&mut data_map_types);
|
||||
@@ -19943,24 +20021,21 @@ impl Game<'_> {
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect();
|
||||
let suffocation = match horizon_client::mobs::suffocation::bind(
|
||||
let suffocation = horizon_client::mobs::suffocation::bind(
|
||||
self.res.core.collision.clone(),
|
||||
&causes_suffocation,
|
||||
) {
|
||||
Ok(binding) => binding,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's data packs are not applied: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(WorldTables {
|
||||
suffocation,
|
||||
files,
|
||||
items,
|
||||
recipes,
|
||||
stations,
|
||||
data_maps,
|
||||
})
|
||||
)?;
|
||||
Ok((
|
||||
Some(WorldTables {
|
||||
suffocation,
|
||||
files,
|
||||
items,
|
||||
recipes,
|
||||
stations,
|
||||
data_maps,
|
||||
}),
|
||||
health,
|
||||
))
|
||||
}
|
||||
/// Reload the content tables with the world's packs, as
|
||||
/// `/reload` reloads a server's (`ReloadableServerResources`).
|
||||
@@ -19968,7 +20043,17 @@ impl Game<'_> {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
self.world_tables = self.build_world_tables();
|
||||
match self.build_world_tables(true, &self.level.enabled_features) {
|
||||
Ok((tables, health)) => {
|
||||
self.world_tables = tables;
|
||||
self.health_resources = health;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's data packs are not applied: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.damage_tag_generation = self.damage_tag_generation.wrapping_add(1);
|
||||
self.recipe_tables.categories = None;
|
||||
horizon_client::structure_blocks::forget_loot_data();
|
||||
// Before the players are sent the reloaded data below.
|
||||
@@ -19991,7 +20076,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
None => lan::forget_sync_data(),
|
||||
None => {
|
||||
if let Err(e) =
|
||||
lan::reload_cached_sync_data(&self.options.resources, &self.pack_files())
|
||||
{
|
||||
log::warn!("Horizon: the cached world data is not reloaded: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let overlay = pack_files(&self.world_tables);
|
||||
self.adv.tree = Arc::new(horizon_client::advancements::Tree::load_with(
|
||||
@@ -21023,6 +21114,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
outline: Vec::new(),
|
||||
outline_after_terrain: false,
|
||||
outline_high_contrast: false,
|
||||
pvp: Default::default(),
|
||||
debug_lines: &[],
|
||||
debug_lines_on_top: &[],
|
||||
debug_crosshair: &[],
|
||||
@@ -21045,6 +21137,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
lightning: Default::default(),
|
||||
additive: &[],
|
||||
see_through: &[],
|
||||
world_primitives: Default::default(),
|
||||
shadow_only: &[],
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
@@ -21177,6 +21270,12 @@ fn start_game<'a>(
|
||||
Some(folder) => {
|
||||
let (seed, creative) = (game.options.seed, game.options.creative);
|
||||
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() {
|
||||
// The loaded-command fixture pins the server's valid minimum.
|
||||
// Client options reject simulationDistance2 (their UI starts at5),
|
||||
// so an options.txt value cannot establish this prerequisite.
|
||||
if std::env::var_os("HORIZON_HARNESS_LOADED_FIXTURE").is_some() {
|
||||
game.settings.simulation_distance = 2;
|
||||
}
|
||||
// Headless, but with the same server and memory connection as play.
|
||||
game.open_integrated(&folder, None, Some(seed), creative, None)?;
|
||||
} else {
|
||||
@@ -21476,6 +21575,7 @@ impl Loading {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.poll_menu_realms(event_loop);
|
||||
if let Some(w) = &self.window {
|
||||
if self.options.as_ref().is_some_and(|o| o.offscreen) {
|
||||
self.draw();
|
||||
|
||||
@@ -205,15 +205,9 @@ impl adv::World for View<'_, '_> {
|
||||
let l = self.0.world.light(p[0], p[1], p[2]);
|
||||
i32::from((l >> 4).max(l & 15))
|
||||
}
|
||||
/// `CampfireBlock.isSmokeyPos`: share the beehive smoke test.
|
||||
fn smokey(&self, p: [i32; 3]) -> bool {
|
||||
(1..=5).any(|d| {
|
||||
self.0
|
||||
.res
|
||||
.facts()
|
||||
.get(self.0.world.block(p[0], p[1] - d, p[2]))
|
||||
.name
|
||||
.ends_with("campfire")
|
||||
})
|
||||
horizon_client::beehive::smokey(&self.0.mob_world_base(&[]), p)
|
||||
}
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
|
||||
let items = &self.0.res.items();
|
||||
@@ -224,12 +218,10 @@ impl adv::World for View<'_, '_> {
|
||||
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
|
||||
"damage_type" => self
|
||||
.0
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.damage_tags
|
||||
.get(tag)
|
||||
.is_some_and(|t| t.contains(id)),
|
||||
.health_resources
|
||||
.damage
|
||||
.id(id)
|
||||
.is_some_and(|source| self.0.health_resources.damage.in_tag(source, tag)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -448,11 +440,94 @@ impl Game<'_> {
|
||||
pub(super) fn player_entity(&self) -> EntityView {
|
||||
self.player_entity_looking(true)
|
||||
}
|
||||
/// [`Self::entity_view_of`] for testing `predicate` (a predicate, a list
|
||||
/// of conditions or an item modifier): the player with the level,
|
||||
/// statistics, advancements and recipes it may ask for, and nothing
|
||||
/// copied when it asks for none.
|
||||
pub(super) fn entity_view_asked(
|
||||
&self,
|
||||
t: super::commands::Target,
|
||||
predicate: &Value,
|
||||
) -> EntityView {
|
||||
self.entity_view_asking(t, adv::facts_asked_in(predicate))
|
||||
}
|
||||
/// [`Self::entity_view_of`] with the player's records `asked` names.
|
||||
pub(super) fn entity_view_asking(
|
||||
&self,
|
||||
t: super::commands::Target,
|
||||
asked: adv::FactsAsked,
|
||||
) -> EntityView {
|
||||
use super::commands::Target;
|
||||
let mut view = self.entity_view_of(t);
|
||||
match t {
|
||||
Target::Player => view.player_facts = self.player_facts(asked),
|
||||
Target::Guest(conn) => view.player_facts = self.guest_facts(conn, asked),
|
||||
_ => {}
|
||||
}
|
||||
view
|
||||
}
|
||||
/// [`Self::player_facts`] of a server's player who is not in place
|
||||
/// (selectors leave out the one in place): its own records, kept on the
|
||||
/// hub while another acts.
|
||||
fn guest_facts(&self, conn: u32, asked: adv::FactsAsked) -> adv::PlayerFacts {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return adv::PlayerFacts::default();
|
||||
};
|
||||
let Some(g) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.filter(|_| self.lan_acting != Some(conn))
|
||||
else {
|
||||
return adv::PlayerFacts::default();
|
||||
};
|
||||
let stats = (host.play.get(&conn).map(|p| &p.stats)).filter(|s| s.loaded);
|
||||
adv::PlayerFacts {
|
||||
level: asked.level.then_some(g.xp_level),
|
||||
stats: stats.filter(|_| asked.stats).map(|s| s.values.clone()),
|
||||
advancements: asked
|
||||
.advancements
|
||||
.then(|| (self.adv.tree.clone(), g.advancements.clone())),
|
||||
recipes: asked.recipes.then(|| g.recipe_book.recipes.clone()),
|
||||
}
|
||||
}
|
||||
/// [`Self::player_entity`] for `trigger`'s criteria: what the player
|
||||
/// looks at only when one of them may ask (a ray against every entity
|
||||
/// otherwise).
|
||||
pub(super) fn player_entity_for(&self, trigger: &str) -> EntityView {
|
||||
self.player_entity_looking(self.adv.tree.may_look(trigger))
|
||||
/// looks at, and its level, statistics, advancements and recipes, only
|
||||
/// when one of them may ask (a ray against every entity and copies of
|
||||
/// the player's records otherwise), and its compound only when one of
|
||||
/// them may test `nbt`.
|
||||
pub(super) fn player_entity_for(&mut self, trigger: &str) -> EntityView {
|
||||
let mut me = self.player_entity_for_any(&[trigger]);
|
||||
if self.adv.tree.may_test_nbt(trigger) {
|
||||
me.nbt = self.player_nbt();
|
||||
}
|
||||
me
|
||||
}
|
||||
/// [`Self::player_entity_for`] for the criteria of several triggers,
|
||||
/// without the player's compound (building it needs `&mut self`).
|
||||
pub(super) fn player_entity_for_any(&self, triggers: &[&str]) -> EntityView {
|
||||
let tree = &self.adv.tree;
|
||||
let mut me = self.player_entity_looking(triggers.iter().any(|t| tree.may_look(t)));
|
||||
let asked = (triggers.iter()).fold(adv::FactsAsked::default(), |all, t| {
|
||||
all.or(tree.player_facts_asked(t))
|
||||
});
|
||||
me.player_facts = self.player_facts(asked);
|
||||
me
|
||||
}
|
||||
/// The player's level, statistics, advancements and recipes that
|
||||
/// `asked` names, for `PlayerPredicate`; records not loaded stay `None`.
|
||||
fn player_facts(&self, asked: adv::FactsAsked) -> adv::PlayerFacts {
|
||||
if self.player.is_none() {
|
||||
return adv::PlayerFacts::default();
|
||||
}
|
||||
adv::PlayerFacts {
|
||||
level: asked.level.then_some(self.experience.level),
|
||||
stats: (asked.stats && self.stats.loaded).then(|| self.stats.values.clone()),
|
||||
advancements: (asked.advancements && self.adv.loaded)
|
||||
.then(|| (self.adv.tree.clone(), self.adv.progress.clone())),
|
||||
recipes: asked
|
||||
.recipes
|
||||
.then(|| self.level.recipe_book.recipes.clone()),
|
||||
}
|
||||
}
|
||||
/// The player as predicates see it, with what it looks at when `looking`.
|
||||
pub(super) fn player_entity_looking(&self, looking: bool) -> EntityView {
|
||||
@@ -565,6 +640,24 @@ impl Game<'_> {
|
||||
armor.damage = m.extra.body_damage.max(0) as u32;
|
||||
item_view(&armor)
|
||||
});
|
||||
let passengers: Vec<EntityView> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|r| r.extra.mount == Some(m.id))
|
||||
.map(|r| self.mob_entity(r))
|
||||
.chain(self.rider_entity(m.id))
|
||||
.collect();
|
||||
// `NbtPredicate.getEntityTagToCompare`: the mob as saved, with the
|
||||
// mobs riding it under `Passengers`, when a criterion may ask.
|
||||
let nbt = self.adv.tree.tests_nbt().then(|| {
|
||||
let uuid = self.persist.uuids.get(&m.id).copied().unwrap_or_default();
|
||||
let raw = self.persist.raw.get(&m.id);
|
||||
let registry = Some(self.pack.assets.registry);
|
||||
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry);
|
||||
let riders = passengers.iter().filter_map(|r| r.nbt.clone()).collect();
|
||||
super::persist::add_passengers(&mut tag, riders);
|
||||
tag
|
||||
});
|
||||
EntityView {
|
||||
kind: m.kind.id.clone(),
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
@@ -576,13 +669,8 @@ impl Game<'_> {
|
||||
.map(|(id, value)| (id.to_owned(), value))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
passengers: self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|r| r.extra.mount == Some(m.id))
|
||||
.map(|r| self.mob_entity(r))
|
||||
.chain(self.rider_entity(m.id))
|
||||
.collect(),
|
||||
passengers,
|
||||
nbt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -897,6 +985,10 @@ impl Game<'_> {
|
||||
let tree = self.adv.tree.clone();
|
||||
let index = self.inventory_index();
|
||||
let list = index.candidates(stacks.iter().map(|s| s.id.as_str()));
|
||||
if list.is_empty() {
|
||||
return;
|
||||
}
|
||||
let me = self.player_entity_for("minecraft:inventory_changed");
|
||||
self.trigger_criteria(&tree, &list, move |c, w| {
|
||||
let slots = &c["slots"];
|
||||
let range = |k: &str, v: f64| {
|
||||
@@ -917,7 +1009,19 @@ impl Game<'_> {
|
||||
for s in &stacks {
|
||||
wanted.retain(|p| !adv::item(p, s, w));
|
||||
}
|
||||
// `InventoryChangeTrigger.TriggerInstance`'s `player`
|
||||
// (`ContextAwarePredicate`), tested by `SimpleCriterionTrigger.trigger`.
|
||||
wanted.is_empty()
|
||||
&& adv::conditions(
|
||||
&c["player"],
|
||||
&adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
},
|
||||
w,
|
||||
)
|
||||
});
|
||||
}
|
||||
/// `PlayerTrigger.LOCATION` every second, and the other per-tick checks.
|
||||
@@ -1012,23 +1116,7 @@ impl Game<'_> {
|
||||
let target = target.clone();
|
||||
let blow = blow.cloned();
|
||||
self.trigger(name, move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let ectx = adv::Entities {
|
||||
this: Some(&target),
|
||||
..pctx
|
||||
};
|
||||
let blow_ok = c["killing_blow"].is_null()
|
||||
|| blow
|
||||
.as_ref()
|
||||
.is_some_and(|d| adv::damage_source(&c["killing_blow"], d, w, me.position));
|
||||
adv::conditions(&c["player"], &pctx, w)
|
||||
&& adv::conditions(&c["entity"], &ectx, w)
|
||||
&& blow_ok
|
||||
adv::killed(c, &me, &target, blow.as_ref(), w)
|
||||
});
|
||||
}
|
||||
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
|
||||
@@ -1414,11 +1502,10 @@ impl Game<'_> {
|
||||
) {
|
||||
let target = if thrower == self.acting_uuid() {
|
||||
// The player is the entity of both triggers.
|
||||
let tree = &self.adv.tree;
|
||||
let me = self.player_entity_looking(
|
||||
tree.may_look("minecraft:thrown_item_picked_up_by_entity")
|
||||
|| tree.may_look("minecraft:thrown_item_picked_up_by_player"),
|
||||
);
|
||||
let me = self.player_entity_for_any(&[
|
||||
"minecraft:thrown_item_picked_up_by_entity",
|
||||
"minecraft:thrown_item_picked_up_by_player",
|
||||
]);
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
|
||||
me
|
||||
} else if let Some(id) = self.mob_by_uuid(thrower) {
|
||||
@@ -1734,6 +1821,18 @@ impl Game<'_> {
|
||||
&& adv::conditions(&c["player"], &pctx, w)
|
||||
});
|
||||
}
|
||||
/// `Entity.startRiding`'s `sendEventAndTriggers`: `START_RIDING_TRIGGER`
|
||||
/// for every `ServerPlayer` in `entityToRide.getIndirectPassengersStream()`.
|
||||
pub(super) fn trigger_started_riding(&mut self, vehicle: u32) {
|
||||
let mounts: Vec<(u32, Option<u32>)> =
|
||||
self.mobs.iter().map(|m| (m.id, m.extra.mount)).collect();
|
||||
let aboard = super::boarding::passenger_vehicles(vehicle, &mounts);
|
||||
self.for_each_player(|g| {
|
||||
if g.riding.is_some_and(|v| aboard.contains(&v)) {
|
||||
g.trigger_player("minecraft:started_riding");
|
||||
}
|
||||
});
|
||||
}
|
||||
/// A trigger with only the player condition (slept_in_bed, started_riding).
|
||||
pub(super) fn trigger_player(&mut self, name: &str) {
|
||||
let me = self.player_entity_for(name);
|
||||
@@ -2240,6 +2339,41 @@ mod tests {
|
||||
assert!(!tree.may_look("minecraft:tick"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nbt_triggers_are_the_ones_that_ask() {
|
||||
let criterion = |trigger: &str, conditions: serde_json::Value| adv::Criterion {
|
||||
name: "c".into(),
|
||||
trigger: trigger.into(),
|
||||
conditions: horizon_world::lazy_json::LazyJson::new(&conditions),
|
||||
};
|
||||
let nbt = serde_json::json!({"entity": [{"condition": "minecraft:entity_properties",
|
||||
"entity": "this", "predicate": {"minecraft:nbt": "{Variant:1}"}}]});
|
||||
let reference = serde_json::json!({"player": [{"condition": "minecraft:reference",
|
||||
"name": "pack:saved"}]});
|
||||
let plain = serde_json::json!({"entity": [{"condition": "minecraft:entity_properties",
|
||||
"entity": "this", "predicate": {"minecraft:flags": {"is_baby": true}}}]});
|
||||
let mut tree = Tree::default();
|
||||
tree.index_nbt();
|
||||
assert!(!tree.tests_nbt());
|
||||
tree.list.push(adv::Advancement {
|
||||
id: "pack:a".into(),
|
||||
parent: None,
|
||||
display: None,
|
||||
criteria: vec![
|
||||
criterion("minecraft:player_interacted_with_entity", nbt),
|
||||
criterion("minecraft:location", reference),
|
||||
criterion("minecraft:tame_animal", plain),
|
||||
],
|
||||
requirements: Vec::new(),
|
||||
rewards: Default::default(),
|
||||
});
|
||||
tree.index_nbt();
|
||||
assert!(tree.tests_nbt());
|
||||
assert!(tree.may_test_nbt("minecraft:player_interacted_with_entity"));
|
||||
assert!(tree.may_test_nbt("minecraft:location"));
|
||||
assert!(!tree.may_test_nbt("minecraft:tame_animal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_labels_are_yellow_or_pink() {
|
||||
// The batch takes colours as `rgba`: 0xFFFF_FF00 there is cyan (#5850).
|
||||
|
||||
@@ -670,6 +670,7 @@ impl Game<'_> {
|
||||
json!({ "mode": "multiplayer", "disconnected": reason })
|
||||
}
|
||||
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
|
||||
Mode::Realms(_) => json!({ "mode": "realms" }),
|
||||
Mode::MultiplayerWarning { check } => {
|
||||
json!({ "mode": "multiplayer-warning", "check": check })
|
||||
}
|
||||
|
||||
@@ -11,6 +11,44 @@ use horizon_client::{
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
impl Game<'_> {
|
||||
/// The client's `Allay.mobInteract` runs before the server's response.
|
||||
pub(super) fn predict_allay_interaction(
|
||||
&mut self,
|
||||
index: usize,
|
||||
hand: Hand,
|
||||
offered: Option<ItemStack>,
|
||||
) -> bool {
|
||||
use allay::interaction::{Interaction, predict};
|
||||
let duplicates = offered.as_ref().is_some_and(|s| {
|
||||
self.items()
|
||||
.item_tags
|
||||
.get("minecraft:duplicates_allays")
|
||||
.is_some_and(|tag| tag.contains(&*s.item))
|
||||
});
|
||||
let actor = self.acting_uuid();
|
||||
let Some(result) = predict(
|
||||
&mut self.mobs[index],
|
||||
offered.as_ref(),
|
||||
hand == Hand::Main,
|
||||
duplicates,
|
||||
actor,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
self.play_mob_bound_sound(index, result.sound(), 6, 2.0, 1.0, false);
|
||||
match result {
|
||||
Interaction::Duplicate | Interaction::Give => {
|
||||
self.with_hand(hand, Self::consume_held);
|
||||
}
|
||||
Interaction::Take(stack) => {
|
||||
// Player.addItem returns leftovers on the client without
|
||||
// spawning an item entity; the server owns any dropped stack.
|
||||
let _ = self.give(stack);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn tick_allays(&mut self) {
|
||||
let griefing = self.ui.rules.bool("mob_griefing");
|
||||
// The playing jukeboxes, found at the first allay: no allay starts
|
||||
@@ -248,8 +286,14 @@ impl Game<'_> {
|
||||
3.0, 3.75, 4.0,
|
||||
];
|
||||
let pitch = PITCHES[self.world.random.next_int_bound(16).unwrap_or(4) as usize];
|
||||
self.audio
|
||||
.play("minecraft:entity.allay.item_thrown", Some(from), 1.0, pitch);
|
||||
self.play_mob_bound_sound(
|
||||
i,
|
||||
"minecraft:entity.allay.item_thrown",
|
||||
6,
|
||||
1.0,
|
||||
pitch,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `Allay.mobInteract`: duplicate when dancing with an amethyst shard,
|
||||
@@ -260,7 +304,12 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let shard = item.is_some_and(|s| &*s.item == "minecraft:amethyst_shard");
|
||||
let shard = item.is_some_and(|s| {
|
||||
self.items()
|
||||
.item_tags
|
||||
.get("minecraft:duplicates_allays")
|
||||
.is_some_and(|tag| tag.contains(&*s.item))
|
||||
});
|
||||
if m.extra.flag && shard && m.extra.egg_time == 0 {
|
||||
let pos = at;
|
||||
self.mobs[index].extra.egg_time = 6000;
|
||||
@@ -272,8 +321,14 @@ impl Game<'_> {
|
||||
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
|
||||
let id = self.mobs[index].id;
|
||||
self.mob_event_particles(id, 18);
|
||||
self.audio
|
||||
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
|
||||
self.play_mob_bound_sound(
|
||||
index,
|
||||
"minecraft:block.amethyst_block.chime",
|
||||
6,
|
||||
2.0,
|
||||
1.0,
|
||||
true,
|
||||
);
|
||||
self.consume_held();
|
||||
return true;
|
||||
}
|
||||
@@ -287,8 +342,14 @@ impl Game<'_> {
|
||||
m.extra.tame = true;
|
||||
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
|
||||
m.extra.owner = Some(acting);
|
||||
self.audio
|
||||
.play("minecraft:entity.allay.item_given", Some(at), 2.0, 1.0);
|
||||
self.play_mob_bound_sound(
|
||||
index,
|
||||
"minecraft:entity.allay.item_given",
|
||||
6,
|
||||
2.0,
|
||||
1.0,
|
||||
true,
|
||||
);
|
||||
self.consume_held();
|
||||
return true;
|
||||
}
|
||||
@@ -305,8 +366,14 @@ impl Game<'_> {
|
||||
let carried = m.extra.allay.as_mut().and_then(|st| st.inventory.take());
|
||||
m.extra.tame = false;
|
||||
m.extra.owner = None;
|
||||
self.audio
|
||||
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
|
||||
self.play_mob_bound_sound(
|
||||
index,
|
||||
"minecraft:entity.allay.item_taken",
|
||||
6,
|
||||
2.0,
|
||||
1.0,
|
||||
true,
|
||||
);
|
||||
// Allay.mobInteract ends with swing(MAIN_HAND, ...).
|
||||
self.mobs[index].swing();
|
||||
if let Some(stack) = carried {
|
||||
|
||||
@@ -638,7 +638,7 @@ impl Game<'_> {
|
||||
}
|
||||
if short {
|
||||
self.audio.play(
|
||||
"minecraft:block.conduit.ambient_short",
|
||||
"minecraft:block.conduit.ambient.short",
|
||||
Some(centre),
|
||||
1.0,
|
||||
1.0,
|
||||
|
||||
@@ -19,7 +19,51 @@ pub(super) fn after_leaving(cooldown: i32, was: Option<u32>, now: Option<u32>) -
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.getIndirectPassengersStream` for `vehicle`: the vehicle and every
|
||||
/// vehicle riding it, directly or through another rider. `mounts` holds
|
||||
/// `(vehicle, its vehicle)` for every mob.
|
||||
pub(super) fn passenger_vehicles(vehicle: u32, mounts: &[(u32, Option<u32>)]) -> Vec<u32> {
|
||||
let mut ids = vec![vehicle];
|
||||
let mut i = 0;
|
||||
while i < ids.len() {
|
||||
let v = ids[i];
|
||||
i += 1;
|
||||
for &(id, mount) in mounts {
|
||||
if mount == Some(v) && !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.startRiding` for the acting player: the rider link, then
|
||||
/// `CriteriaTriggers.START_RIDING_TRIGGER` for every server player the
|
||||
/// vehicle carries.
|
||||
pub(super) fn start_riding(&mut self, vehicle: u32) {
|
||||
self.riding = Some(vehicle);
|
||||
self.note_started_riding(vehicle);
|
||||
}
|
||||
|
||||
/// A rider link changed on `vehicle` (`sendEventAndTriggers`). Every
|
||||
/// player aboard hears the trigger; a host's turn can fire it now, while
|
||||
/// a guest's turn (the hub held by its action) leaves it for
|
||||
/// [`Self::flush_started_riding`] once every player is back in place.
|
||||
pub(super) fn note_started_riding(&mut self, vehicle: u32) {
|
||||
self.started_riding.push(vehicle);
|
||||
if self.lan.is_some() && self.lan_acting.is_none() {
|
||||
self.flush_started_riding();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fires the queued `started_riding` triggers with every player in place.
|
||||
pub(super) fn flush_started_riding(&mut self) {
|
||||
for vehicle in std::mem::take(&mut self.started_riding) {
|
||||
self.trigger_started_riding(vehicle);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.canRide` for the player acting now (the host's, or the guest
|
||||
/// whose turn it is): the cooldown has elapsed, and the player did not
|
||||
/// leave a vehicle since the tick began (the tick that starts the
|
||||
@@ -60,4 +104,17 @@ mod tests {
|
||||
fn never_negative() {
|
||||
assert_eq!(tick_down(0), 0);
|
||||
}
|
||||
#[test]
|
||||
fn started_riding_reaches_every_rider_of_the_vehicle() {
|
||||
// Entity.startRiding: START_RIDING_TRIGGER for every ServerPlayer in
|
||||
// entityToRide.getIndirectPassengersStream(). A mob between two
|
||||
// riders is still part of that stream, and another vehicle is not.
|
||||
let mounts = [(7, Some(5)), (8, Some(7)), (9, Some(4))];
|
||||
let aboard = passenger_vehicles(5, &mounts);
|
||||
assert!(aboard.contains(&5), "the rider on the vehicle itself");
|
||||
assert!(aboard.contains(&7), "the mob in the vehicle's seat");
|
||||
assert!(aboard.contains(&8), "the rider of that mob");
|
||||
assert!(!aboard.contains(&9), "a mob riding another vehicle");
|
||||
assert!(!aboard.contains(&6), "a mob riding nothing");
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,7 @@ impl Game<'_> {
|
||||
count += 1;
|
||||
// Mob.startRiding drops its lead.
|
||||
self.break_leash(mob, true, false);
|
||||
self.note_started_riding(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
|
||||
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
|
||||
//! saving and `AbstractBoatRenderer` with animated paddles.
|
||||
mod placement;
|
||||
|
||||
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
|
||||
use horizon_client::entity_rules::EntityRules;
|
||||
use horizon_client::{
|
||||
@@ -1251,48 +1253,7 @@ impl Game<'_> {
|
||||
if !self.boat_models.has(wood, chest) {
|
||||
return None;
|
||||
}
|
||||
let (eye, dir) = self.aim()?;
|
||||
let reach = self.reach();
|
||||
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
|
||||
// block along the ray.
|
||||
let block = self.pick().map(|h| {
|
||||
(
|
||||
h.point,
|
||||
(0..3)
|
||||
.map(|a| (h.point[a] - eye[a]).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt(),
|
||||
)
|
||||
});
|
||||
let mut fluid = None;
|
||||
let mut t = 0.0;
|
||||
while t < reach {
|
||||
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
|
||||
let (x, y, z) = (
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
);
|
||||
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
|
||||
&& p[1] <= f64::from(y) + f64::from(h)
|
||||
{
|
||||
fluid = Some((fluid_hit_point(p, y, h), t));
|
||||
break;
|
||||
}
|
||||
t += 0.05;
|
||||
}
|
||||
let hit = match (block, fluid) {
|
||||
(Some(b), Some(w)) => {
|
||||
if w.1 < b.1 {
|
||||
w.0
|
||||
} else {
|
||||
b.0
|
||||
}
|
||||
}
|
||||
(Some(b), None) => b.0,
|
||||
(None, Some(w)) => w.0,
|
||||
(None, None) => return None,
|
||||
};
|
||||
let hit = self.boat_item_hit()?;
|
||||
let mut boat = Boat::new(
|
||||
self.next_entity + 1,
|
||||
Arc::from(wood),
|
||||
@@ -1387,10 +1348,10 @@ impl Game<'_> {
|
||||
if !self.native_may_board(self.boats[i].id) {
|
||||
return true;
|
||||
}
|
||||
self.riding = Some(self.boats[i].id);
|
||||
let id = self.boats[i].id;
|
||||
self.yaw = self.boats[i].yaw;
|
||||
// ServerPlayer.startRiding.
|
||||
self.trigger_player("minecraft:started_riding");
|
||||
// Entity.startRiding (ServerPlayer.startRiding fires the trigger).
|
||||
self.start_riding(id);
|
||||
true
|
||||
}
|
||||
/// `ContainerEntity.interactWithContainerVehicle` (`AbstractChestBoat`):
|
||||
@@ -1623,36 +1584,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a fluid pick lands in the cell the ray sample `p` is in. Vanilla's
|
||||
/// `ClipContext.Fluid.ANY` clips against the fluid shape: a full-height cell
|
||||
/// (the same fluid above it) is a full cube, so the hit is the ray's own
|
||||
/// point and a boat placed under water stays at the eye; a partial cell is
|
||||
/// clipped to its surface.
|
||||
fn fluid_hit_point(p: [f64; 3], y: i32, h: f32) -> [f64; 3] {
|
||||
if h >= 1.0 {
|
||||
p
|
||||
} else {
|
||||
[p[0], f64::from(y) + f64::from(h), p[2]]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `VoxelShape.clip` on a full-cube fluid cell keeps the true hit point
|
||||
/// (a ray starting inside hits at `from + delta * 0.001`); only a partial
|
||||
/// cell is clipped to its surface.
|
||||
#[test]
|
||||
fn a_full_height_fluid_cell_keeps_the_ray_point_and_a_partial_one_snaps_to_its_surface() {
|
||||
let eye = [0.5, 3.62, 0.5];
|
||||
assert_eq!(fluid_hit_point(eye, 3, 1.0), eye);
|
||||
assert_eq!(
|
||||
fluid_hit_point(eye, 3, 8.0 / 9.0),
|
||||
[0.5, 3.0 + f64::from(8.0f32 / 9.0), 0.5]
|
||||
);
|
||||
}
|
||||
|
||||
fn boat() -> Boat {
|
||||
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! BoatItem's server POV clip; deliberately separate from renderer/mining picks.
|
||||
use super::{Fluid, Game};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
registry::BlockStateId,
|
||||
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
|
||||
};
|
||||
use std::io;
|
||||
|
||||
struct BoatItemRay<'a, 'r>(&'a Game<'r>);
|
||||
impl RayWorld for BoatItemRay<'_, '_> {
|
||||
type Error = io::Error;
|
||||
type Block = BlockStateId;
|
||||
type Fluid = Option<(f32, bool, Fluid)>;
|
||||
fn block_state(&mut self, p: BlockPos) -> io::Result<BlockStateId> {
|
||||
Ok(self.0.world.block(p.x, p.y, p.z))
|
||||
}
|
||||
fn fluid_state(&mut self, p: BlockPos) -> io::Result<Self::Fluid> {
|
||||
Ok(self
|
||||
.0
|
||||
.fluid_height_of(p.x, p.y, p.z, None)
|
||||
.map(|(h, source)| {
|
||||
(
|
||||
h,
|
||||
source,
|
||||
self.0
|
||||
.res
|
||||
.facts()
|
||||
.get(self.0.world.block(p.x, p.y, p.z))
|
||||
.fluid,
|
||||
)
|
||||
}))
|
||||
}
|
||||
fn block_shape(
|
||||
&mut self,
|
||||
state: BlockStateId,
|
||||
p: BlockPos,
|
||||
_: RayBlockMode,
|
||||
) -> io::Result<VoxelShape> {
|
||||
// LiquidBlock.getShape is empty. Waterlogged blocks retain their outline.
|
||||
if matches!(
|
||||
&*self.0.res.facts().get(state).name,
|
||||
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
|
||||
) {
|
||||
return Ok(VoxelShape::empty());
|
||||
}
|
||||
let mut shape = VoxelShape::empty();
|
||||
for b in self.0.outline_boxes([p.x, p.y, p.z]) {
|
||||
let b: [f64; 6] = std::array::from_fn(|i| b[i] - f64::from([p.x, p.y, p.z][i % 3]));
|
||||
let part = VoxelShape::from_box(Aabb::new(
|
||||
Vec3::new(b[0], b[1], b[2]),
|
||||
Vec3::new(b[3], b[4], b[5]),
|
||||
))
|
||||
.map_err(|e| io::Error::other(format!("{e:?}")))?;
|
||||
shape = shape
|
||||
.join(&part, ShapeOp::OR)
|
||||
.map_err(|e| io::Error::other(format!("{e:?}")))?;
|
||||
}
|
||||
Ok(shape)
|
||||
}
|
||||
fn interaction_shape(&mut self, _: BlockStateId, _: BlockPos) -> io::Result<VoxelShape> {
|
||||
// Pinned vanilla's registered interaction shapes are all empty;
|
||||
// interaction_shapes::InteractionShapes validates that same table.
|
||||
Ok(VoxelShape::empty())
|
||||
}
|
||||
fn fluid_is_source(&mut self, s: Self::Fluid) -> io::Result<bool> {
|
||||
Ok(s.is_some_and(|s| s.1))
|
||||
}
|
||||
fn fluid_is_empty(&mut self, s: Self::Fluid) -> io::Result<bool> {
|
||||
Ok(s.is_none())
|
||||
}
|
||||
fn fluid_is_water(&mut self, s: Self::Fluid) -> io::Result<bool> {
|
||||
Ok(s.is_some_and(|s| s.2 == Fluid::Water))
|
||||
}
|
||||
fn fluid_shape(&mut self, s: Self::Fluid, _: BlockPos) -> io::Result<VoxelShape> {
|
||||
match s {
|
||||
None => Ok(VoxelShape::empty()),
|
||||
Some((h, _, _)) => {
|
||||
VoxelShape::from_box(Aabb::new(Vec3::ZERO, Vec3::new(1.0, f64::from(h), 1.0)))
|
||||
.map_err(|e| io::Error::other(format!("{e:?}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `Item.getPlayerPOVHitResult`: float view vector, OUTLINE and Fluid.ANY.
|
||||
pub(super) fn boat_item_hit(&self) -> Option<[f64; 3]> {
|
||||
let [x, y, z] = self.eye_position()?;
|
||||
let start = Vec3::new(x, y, z);
|
||||
let dir = horizon_world::view_direction::view_vector(self.pitch, self.yaw);
|
||||
let end = start.add_vector(dir.scale(self.reach()));
|
||||
let hit = clip_world(
|
||||
&mut BoatItemRay(self),
|
||||
start,
|
||||
end,
|
||||
RayBlockMode::Outline,
|
||||
RayFluidMode::Any,
|
||||
256,
|
||||
)
|
||||
.ok()?;
|
||||
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,6 @@ impl UiState {
|
||||
/// instead of at every world opening (thousands of files).
|
||||
struct ResourceIds {
|
||||
recipes: Vec<String>,
|
||||
damage_types: Vec<String>,
|
||||
loot_tables: Vec<String>,
|
||||
templates: Vec<String>,
|
||||
/// `entity-types.json`'s keys, sorted (empty when the file is missing).
|
||||
@@ -287,7 +286,6 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
let (timelines, markers) = overworld_timelines(root);
|
||||
let ids = Arc::new(ResourceIds {
|
||||
recipes: ids_under(&data.join("recipe"), "json"),
|
||||
damage_types: ids_under(&data.join("damage_type"), "json"),
|
||||
// `/loot ... loot <id>` must find a content mod's tables too.
|
||||
loot_tables: json_ids_of_every_namespace(root, "loot_table"),
|
||||
templates: templates_under(&root.join("worldgen/structure")),
|
||||
@@ -549,7 +547,12 @@ impl Game<'_> {
|
||||
reg.permission = permission;
|
||||
let ids = resource_ids(&self.options.resources);
|
||||
self.ui.registries.recipes = ids.recipes.clone();
|
||||
self.ui.registries.damage_types = ids.damage_types.clone();
|
||||
self.ui.registries.damage_types = self
|
||||
.health_resources
|
||||
.damage
|
||||
.entries()
|
||||
.map(|(_, d)| d.name.clone())
|
||||
.collect();
|
||||
self.ui.registries.loot_tables = ids.loot_tables.clone();
|
||||
self.ui.registries.templates = ids.templates.clone();
|
||||
self.ui.registries.sounds = self.audio.event_names();
|
||||
@@ -759,6 +762,16 @@ impl Game<'_> {
|
||||
/// sent); blank text discards the draft. A draft still shown unchanged
|
||||
/// stays as it is.
|
||||
fn keep_chat_draft(&mut self, text: &str, reason: ChatExit) {
|
||||
// During PvP & HUD's chat search the box holds the query: the draft is the text the
|
||||
// search put aside, whichever way the chat closed.
|
||||
let aside;
|
||||
let text = if self.pvp.chat_search {
|
||||
self.pvp.chat_search = false;
|
||||
aside = std::mem::take(&mut self.pvp.chat_search_aside);
|
||||
aside.as_str()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let unchanged =
|
||||
self.ui.chat_draft_shown && self.ui.chat_draft.as_ref().is_some_and(|d| d.text == text);
|
||||
if should_discard_draft(reason, self.settings.file.bool("saveChatDrafts"))
|
||||
@@ -814,6 +827,40 @@ impl Game<'_> {
|
||||
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
|
||||
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
|
||||
}
|
||||
/// The Leave Bed button's draws. It registers as a button
|
||||
/// (`widgets::record`), so the pad's and the keyboard's focus reach it (the
|
||||
/// pad's starts on it) and `horizon-ctl click-button` finds it.
|
||||
fn leave_bed_draws(
|
||||
m: &Measure,
|
||||
mouse: (i32, i32),
|
||||
width: f32,
|
||||
height: f32,
|
||||
) -> Vec<horizon_client::screen_draw::Draw> {
|
||||
let (x, y, w, h) = Self::leave_bed_button(width, height);
|
||||
let label = Text::literal(horizon_client::lang::get_or(
|
||||
"multiplayer.stopSleeping",
|
||||
"Leave Bed",
|
||||
));
|
||||
horizon_client::widgets::record(
|
||||
&label.plain(),
|
||||
[x as f32, y as f32, w as f32, h as f32],
|
||||
true,
|
||||
);
|
||||
let mut button = Vec::new();
|
||||
horizon_client::screen_draw::button(
|
||||
&mut button,
|
||||
m,
|
||||
&label,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
true,
|
||||
horizon_client::screen_draw::inside(mouse, x, y, w, h),
|
||||
horizon_client::hud_overlays::now_ms(),
|
||||
);
|
||||
button
|
||||
}
|
||||
/// `Minecraft.computeChatAbilities` from the Chat option.
|
||||
pub(super) fn chat_abilities(&self) -> ChatAbilities {
|
||||
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
|
||||
@@ -890,6 +937,17 @@ impl Game<'_> {
|
||||
// while a screen is open.
|
||||
let ctrl = self.has_control_down_with_quirk();
|
||||
let shift = self.has_shift_down();
|
||||
// PvP & HUD's chat search: Ctrl+F starts and stops it, Enter stops it (sends nothing).
|
||||
if self.settings.extras.pvp.chat.search
|
||||
&& match key {
|
||||
KeyCode::KeyF => ctrl,
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => self.pvp.chat_search,
|
||||
_ => false,
|
||||
}
|
||||
{
|
||||
self.toggle_chat_search();
|
||||
return;
|
||||
}
|
||||
match key {
|
||||
// ChatScreen.keyPressed: the chat scrolls a page.
|
||||
KeyCode::PageUp | KeyCode::PageDown
|
||||
@@ -917,7 +975,8 @@ impl Game<'_> {
|
||||
};
|
||||
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
|
||||
ChatAction::Close => {
|
||||
// Escape: the box still holds its text, which close_chat keeps as the draft.
|
||||
// Escape: the box still holds its text, which close_chat keeps as the draft
|
||||
// (a search's query gives way to the text it put aside: keep_chat_draft).
|
||||
self.ui.chat_screen = Some(screen);
|
||||
self.close_chat(ChatExit::Intentional);
|
||||
}
|
||||
@@ -1063,12 +1122,29 @@ impl Game<'_> {
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let Some(style) = self.ui.chat.style_at(
|
||||
let style = self.ui.chat.style_at(
|
||||
&self.gui.atlas.font,
|
||||
height as i32,
|
||||
cursor.0 as f64,
|
||||
cursor.1 as f64,
|
||||
) else {
|
||||
);
|
||||
// PvP & HUD's copy on click: a message where the click has nothing else to do.
|
||||
if self.settings.extras.pvp.chat.copy
|
||||
&& !self.has_shift_down()
|
||||
&& style.as_ref().is_none_or(|s| s.click.is_none())
|
||||
&& let Some(message) =
|
||||
self.ui
|
||||
.chat
|
||||
.message_at(height as i32, cursor.0 as f64, cursor.1 as f64)
|
||||
{
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(message);
|
||||
let copied =
|
||||
Text::translate_with_fallback("horizon.chat.copied", "Message copied", &[]);
|
||||
self.titles.set_overlay(copied, false);
|
||||
return;
|
||||
}
|
||||
let Some(style) = style else {
|
||||
return;
|
||||
};
|
||||
// ChatScreen.handleComponentClicked: with Shift held only an
|
||||
@@ -1206,9 +1282,47 @@ impl Game<'_> {
|
||||
self.run_command(command);
|
||||
}
|
||||
}
|
||||
/// PvP & HUD's chat search on or off. The box holds the query: what was typed is put aside
|
||||
/// when the search starts and comes back when it stops.
|
||||
fn toggle_chat_search(&mut self) {
|
||||
let typed = self.ui.chat_screen.as_ref().map(|s| s.value().to_owned());
|
||||
let next = if self.pvp.chat_search {
|
||||
std::mem::take(&mut self.pvp.chat_search_aside)
|
||||
} else {
|
||||
self.pvp.chat_search_aside = typed.unwrap_or_default();
|
||||
String::new()
|
||||
};
|
||||
self.pvp.chat_search = !self.pvp.chat_search;
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
screen.suggest_command(&m, &c, &next);
|
||||
}
|
||||
}
|
||||
/// Chat lines over the HUD (focused while the chat screen is open).
|
||||
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let options = self.settings.extras.pvp.chat;
|
||||
self.ui.chat.set_qol(&self.gui.atlas, options.qol());
|
||||
// A search ends with its option; a chat closed some other way ends it in keep_chat_draft.
|
||||
if self.pvp.chat_search && self.ui.chat_screen.is_none() {
|
||||
// The chat went without closing (leaving the world): nothing to give back.
|
||||
self.pvp.chat_search = false;
|
||||
self.pvp.chat_search_aside.clear();
|
||||
} else if self.pvp.chat_search && !options.search {
|
||||
self.toggle_chat_search();
|
||||
}
|
||||
let query = self
|
||||
.ui
|
||||
.chat_screen
|
||||
.as_ref()
|
||||
.filter(|_| focused && self.pvp.chat_search)
|
||||
.map(|s| s.value());
|
||||
self.ui.chat.set_search(&self.gui.atlas, query);
|
||||
self.ui.chat.search_prompt = focused && self.pvp.chat_search;
|
||||
if self.ui.hide_gui && !focused {
|
||||
return;
|
||||
}
|
||||
@@ -1290,24 +1404,7 @@ impl Game<'_> {
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
|
||||
self.ui.chat_screen = Some(screen);
|
||||
if in_bed {
|
||||
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
|
||||
let label = Text::literal(horizon_client::lang::get_or(
|
||||
"multiplayer.stopSleeping",
|
||||
"Leave Bed",
|
||||
));
|
||||
let mut button = Vec::new();
|
||||
horizon_client::screen_draw::button(
|
||||
&mut button,
|
||||
&m,
|
||||
&label,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
true,
|
||||
horizon_client::screen_draw::inside(mouse, x, y, w, h),
|
||||
horizon_client::hud_overlays::now_ms(),
|
||||
);
|
||||
let button = Self::leave_bed_draws(&m, mouse, batch.width, batch.height);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
|
||||
}
|
||||
// The hovered message part's tooltip (`renderComponentHoverEffect`),
|
||||
@@ -1897,4 +1994,73 @@ mod tests {
|
||||
assert_eq!(prefilled.summonable, vec!["minecraft:pig".to_string()]);
|
||||
assert_eq!(prefilled.entity_types, vec!["minecraft:pig".to_string()]);
|
||||
}
|
||||
|
||||
/// The sleeper's chat: Leave Bed is a button target for the pad and the
|
||||
/// keyboard, right above the chat box, and the D-pad steps between the
|
||||
/// two.
|
||||
#[test]
|
||||
fn leave_bed_is_a_focus_target_above_the_chat_box() {
|
||||
use super::super::key_focus;
|
||||
use horizon_client::{
|
||||
font::Font,
|
||||
gamepad::{Button, focus_step_strict},
|
||||
widgets::Kind,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
// The missing-glyph metrics measure without game assets.
|
||||
let font = Font::load(
|
||||
Path::new("/nonexistent-horizon-chat-test-resources"),
|
||||
&HashMap::new(),
|
||||
&[],
|
||||
(1, 1),
|
||||
(0, 0),
|
||||
horizon_client::hud::Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 1,
|
||||
h: 1,
|
||||
},
|
||||
);
|
||||
let m = Measure(&font);
|
||||
let registries = Registries::default();
|
||||
let c = Commands {
|
||||
dispatcher: horizon_client::command::vanilla::game_dispatcher(),
|
||||
registries: ®istries,
|
||||
};
|
||||
let (width, height) = (427.0, 240.0);
|
||||
let mut screen = ChatScreen::new(
|
||||
&m,
|
||||
&c,
|
||||
"",
|
||||
0,
|
||||
width as i32,
|
||||
height as i32,
|
||||
ChatAbilities::new(ChatVisibility::Full),
|
||||
);
|
||||
screen.draw(&m, (0, 0), 0, 0);
|
||||
Game::leave_bed_draws(&m, (0, 0), width, height);
|
||||
horizon_client::widgets::end_frame(u64::MAX);
|
||||
|
||||
let targets = key_focus::drawn_targets();
|
||||
let (x, y, w, h) = Game::leave_bed_button(width, height);
|
||||
let bed = targets
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|t| t.rect == [x as f32, y as f32, w as f32, h as f32])
|
||||
.expect("Leave Bed is a focus target");
|
||||
assert_eq!(bed.kind, Kind::Button);
|
||||
assert!(key_focus::keyboard_targets(targets.clone(), false).contains(&bed));
|
||||
let field = targets
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|t| t.kind == Kind::Field)
|
||||
.expect("the chat box is a focus target");
|
||||
let rects: Vec<_> = targets.iter().map(|t| t.rect).collect();
|
||||
let step =
|
||||
|from: [f32; 4], button| focus_step_strict(from, button, &rects).map(|i| targets[i]);
|
||||
assert_eq!(step(field.rect, Button::DPadUp), Some(bed));
|
||||
assert_eq!(step(bed.rect, Button::DPadDown), Some(field));
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,26 @@ const MUTE_LEVEL: u8 = 3;
|
||||
/// The commands of `mute_command`.
|
||||
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
|
||||
|
||||
/// The mute commands as operators' clients get them in the command tree (`command_tree`'s usages).
|
||||
pub(super) const MUTE_USAGES: [&str; 4] = [
|
||||
"mute <player:player> [duration_and_reason:text]",
|
||||
"tempmute <player:player> <duration:word> [reason:text]",
|
||||
"mutelist",
|
||||
"unmute <player:player>",
|
||||
];
|
||||
|
||||
/// `/mute <player> [duration] [reason]` (`words`, the command first): how long, when the word after
|
||||
/// the name is a duration (`10m`, `2h`, `1d`; none: until `/unmute`), and the index the reason starts at.
|
||||
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
|
||||
match words
|
||||
.get(2)
|
||||
.and_then(|w| horizon_chat_filter::parse_duration(w))
|
||||
{
|
||||
Some(ms) => (Some(ms), 3),
|
||||
None => (None, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reason a temporary ban gives at login.
|
||||
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
|
||||
|
||||
@@ -295,7 +315,10 @@ impl Game<'_> {
|
||||
lines
|
||||
}
|
||||
["unmute", name] => vec![self.unmute(host, name, by)],
|
||||
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), by)],
|
||||
["mute", name, ..] => {
|
||||
let (time, from) = mute_time(words);
|
||||
vec![self.mute(host, name, time, reason(from), by)]
|
||||
}
|
||||
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![Reply::translated(
|
||||
@@ -498,4 +521,17 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mute_takes_a_duration_after_the_name_or_mutes_until_unmuted() {
|
||||
assert_eq!(mute_time(&["mute", "Steve"]), (None, 2));
|
||||
assert_eq!(mute_time(&["mute", "Steve", "spamming", "chat"]), (None, 2));
|
||||
assert_eq!(
|
||||
mute_time(&["mute", "Steve", "1h", "spam"]),
|
||||
(Some(3_600_000), 3)
|
||||
);
|
||||
assert_eq!(mute_time(&["mute", "Steve", "30m"]), (Some(1_800_000), 3));
|
||||
// Not a duration: the word starts the reason.
|
||||
assert_eq!(mute_time(&["mute", "Steve", "0m", "spam"]), (None, 2));
|
||||
}
|
||||
}
|
||||
@@ -295,7 +295,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => {
|
||||
let Some(d) = self.motion.health.damage.definition(*source) else {
|
||||
let Some(d) = self.health_resources.damage.definition(*source) else {
|
||||
continue;
|
||||
};
|
||||
(d.name.clone(), *amount)
|
||||
@@ -394,7 +394,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
fn damage_in_tag(&self, name: &str, tag: &str) -> bool {
|
||||
let damage = &self.motion.health.damage;
|
||||
let damage = &self.health_resources.damage;
|
||||
damage.id(name).is_some_and(|id| {
|
||||
damage.in_tag(id, tag) || damage.in_tag(id, &format!("minecraft:{tag}"))
|
||||
})
|
||||
@@ -472,11 +472,10 @@ impl Game<'_> {
|
||||
return Text::translate("death.attack.generic", &[me]);
|
||||
};
|
||||
let def = self
|
||||
.motion
|
||||
.health
|
||||
.health_resources
|
||||
.damage
|
||||
.id(&killing.damage_type)
|
||||
.and_then(|id| self.motion.health.damage.definition(id))
|
||||
.and_then(|id| self.health_resources.damage.definition(id))
|
||||
.map(|d| (d.message_id.clone(), d.death_message_type.clone()))
|
||||
.unwrap_or_else(|| ("generic".into(), "default".into()));
|
||||
self.combat.entries = entries;
|
||||
|
||||
@@ -51,8 +51,27 @@ impl Game<'_> {
|
||||
/// boundary through Integrated::stop, including script and save failures.
|
||||
pub(super) fn wait_command_harness(&mut self) -> Result<()> {
|
||||
let server = self.integrated.take().ok_or("command harness: no server")?;
|
||||
let client_config = std::env::var_os("HORIZON_HARNESS_CLIENT_CONFIG");
|
||||
let mut wrote_config = false;
|
||||
while !server.stopped() {
|
||||
self.poll_stationary_client()?;
|
||||
if !wrote_config
|
||||
&& let Some(path) = &client_config
|
||||
&& let Some(remote) = self.remote.as_ref().filter(|r| r.logged_in && r.joined)
|
||||
{
|
||||
std::fs::write(
|
||||
path,
|
||||
json!({
|
||||
"view_distance": remote.view_distance,
|
||||
"simulation_distance": remote.simulation_distance,
|
||||
"render_setting": self.settings.render_distance,
|
||||
"simulation_setting": self.settings.simulation_distance,
|
||||
"logged_in": remote.logged_in, "joined": remote.joined,
|
||||
})
|
||||
.to_string(),
|
||||
)?;
|
||||
wrote_config = true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
server.stop()
|
||||
@@ -127,7 +146,7 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// Load terrain until the player stands in lit chunks.
|
||||
fn harness_settle(&mut self, deadline: Instant) -> Result<()> {
|
||||
fn harness_settle(&mut self, deadline: Instant, required: Option<&Value>) -> Result<()> {
|
||||
loop {
|
||||
self.update_world();
|
||||
if let Some(p) = &self.player {
|
||||
@@ -137,8 +156,23 @@ impl Game<'_> {
|
||||
);
|
||||
// Finish requested terrain before inspecting it, so worker
|
||||
// timing cannot change which command positions are loaded.
|
||||
if !self.streaming.busy()
|
||||
&& (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
|
||||
if (std::env::var_os("HORIZON_HARNESS_TERRAIN_ONLY").is_some()
|
||||
|| !self.streaming.busy())
|
||||
&& required.and_then(Value::as_array).map_or_else(
|
||||
|| {
|
||||
(-1..=1).all(|dz| {
|
||||
(-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))
|
||||
})
|
||||
},
|
||||
|chunks| {
|
||||
chunks.iter().all(|chunk| {
|
||||
chunk[0]
|
||||
.as_i64()
|
||||
.zip(chunk[1].as_i64())
|
||||
.is_some_and(|(x, z)| self.streaming.is_lit(x as i32, z as i32))
|
||||
})
|
||||
},
|
||||
)
|
||||
{
|
||||
// The oracle's settle also waits for the entities saved
|
||||
// in those chunks (areEntitiesActuallyLoadedAndTicking):
|
||||
@@ -182,7 +216,7 @@ impl Game<'_> {
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
self.harness_owner(|g| g.harness_settle(deadline, None))?;
|
||||
let mut out = String::new();
|
||||
for line in std::fs::read_to_string(script)?.lines() {
|
||||
if line.trim().is_empty() {
|
||||
@@ -212,6 +246,76 @@ impl Game<'_> {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"server_distances" => {
|
||||
let state = self.harness_owner(|g| {
|
||||
let hub = &g.lan.as_ref().ok_or("no connected hub")?.hub;
|
||||
Ok([hub.config.view_distance, hub.config.simulation_distance])
|
||||
})?;
|
||||
out.push_str(
|
||||
&json!({"cmd": "#server_distances", "messages": [],
|
||||
"results": state.map(|n| json!([true, n]))})
|
||||
.to_string(),
|
||||
);
|
||||
out.push('\n');
|
||||
}
|
||||
"chunk_ready" => {
|
||||
let [x, z] =
|
||||
std::array::from_fn(|i| op["chunk"][i].as_i64().unwrap_or(0) as i32);
|
||||
let state = self.harness_owner(|g| {
|
||||
let resident = g.world.chunks.contains_key(&(x, z));
|
||||
Ok([
|
||||
resident,
|
||||
g.streaming.is_lit(x, z),
|
||||
resident
|
||||
&& !g
|
||||
.world
|
||||
.loaded_entities
|
||||
.iter()
|
||||
.any(|(key, _)| *key == (x, z)),
|
||||
])
|
||||
})?;
|
||||
out.push_str(
|
||||
&json!({"cmd": format!("#chunk_ready {x} {z}"), "messages": [],
|
||||
"results": state.map(|ready| json!([true, i32::from(ready)]))})
|
||||
.to_string(),
|
||||
);
|
||||
out.push('\n');
|
||||
}
|
||||
"wait_chunks" => {
|
||||
let targets: Vec<(i32, i32)> = op["chunks"]
|
||||
.as_array()
|
||||
.ok_or("missing chunks")?
|
||||
.iter()
|
||||
.map(|p| {
|
||||
(
|
||||
p[0].as_i64().unwrap_or(0) as i32,
|
||||
p[1].as_i64().unwrap_or(0) as i32,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
self.harness_owner(|g| {
|
||||
loop {
|
||||
g.update_world();
|
||||
g.spawn_loaded_entities();
|
||||
if targets.iter().all(|&(x, z)| {
|
||||
g.world.chunks.contains_key(&(x, z))
|
||||
&& g.streaming.is_lit(x, z)
|
||||
&& !g
|
||||
.world
|
||||
.loaded_entities
|
||||
.iter()
|
||||
.any(|(key, _)| *key == (x, z))
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err("initial target readiness timed out".into());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"weather_state" => {
|
||||
// Saved state guards, separate from effective-weather predicates.
|
||||
let state = self.harness_owner(|g| {
|
||||
@@ -240,7 +344,8 @@ impl Game<'_> {
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
for _ in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
let paced_start = Instant::now();
|
||||
for i in 0..op["n"].as_u64().unwrap_or(1) {
|
||||
// The oracle's profile resolver answers at once; the
|
||||
// game's asks the services (none here: they refuse at
|
||||
// once) on a thread, which a tick can outrun.
|
||||
@@ -251,6 +356,19 @@ impl Game<'_> {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
self.tick_server(false)?;
|
||||
if let Some(ms) = op["pace_ms"].as_u64() {
|
||||
std::thread::sleep(
|
||||
Duration::from_millis(ms * (i + 1))
|
||||
.saturating_sub(paced_start.elapsed()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if op["pace_ms"].is_number() {
|
||||
eprintln!(
|
||||
"harness paced wait: {} ticks, {} ms",
|
||||
op["n"],
|
||||
paced_start.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
if op["record"].as_bool() == Some(true) {
|
||||
@@ -272,7 +390,53 @@ impl Game<'_> {
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(2));
|
||||
}
|
||||
self.harness_owner(|g| g.harness_settle(deadline))?;
|
||||
self.harness_owner(|g| g.harness_settle(deadline, op.get("required_chunks")))?;
|
||||
}
|
||||
"use_item" if op.get("sequence").is_some() => {
|
||||
// Exercise the connected owner's normal server packet route.
|
||||
let sequence = op["sequence"].as_i64().unwrap_or(0) as i32;
|
||||
let yaw = op["yaw"].as_f64().unwrap_or(0.0) as f32;
|
||||
let pitch = op["pitch"].as_f64().unwrap_or(0.0) as f32;
|
||||
let owner = self
|
||||
.serving
|
||||
.as_ref()
|
||||
.ok_or("command harness: no server")?
|
||||
.owner;
|
||||
self.in_owner_level(|g| -> Result<()> {
|
||||
let conn = g
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|guest| guest.uuid == owner && guest.placed)
|
||||
.map(|guest| guest.conn)
|
||||
})
|
||||
.ok_or("command harness: owner is not connected")?;
|
||||
let mut host = g.lan.take().ok_or("command harness: no hub")?;
|
||||
g.lan_guest_packet(
|
||||
&mut host,
|
||||
conn,
|
||||
horizon_protocol::packets::play::Serverbound::UseItem(
|
||||
horizon_protocol::packets::play::UseItem {
|
||||
hand: 0,
|
||||
yaw,
|
||||
pitch,
|
||||
sequence,
|
||||
},
|
||||
),
|
||||
);
|
||||
let ack = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.is_some_and(|g| g.ack_up_to.get() >= sequence);
|
||||
g.lan = Some(host);
|
||||
if !ack {
|
||||
return Err("command harness: UseItem was not acknowledged".into());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
// Java produced the fixture file before this world was opened; map_state loads it.
|
||||
"crafter_max_map_fixture" => {}
|
||||
|
||||
@@ -1516,7 +1516,7 @@ impl Game<'_> {
|
||||
// An entity in another level is teleported in the level that holds it.
|
||||
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation, turn_riders));
|
||||
}
|
||||
fn teleport_to_in_level(
|
||||
pub(super) fn teleport_to_in_level(
|
||||
&mut self,
|
||||
t: Target,
|
||||
pos: [f64; 3],
|
||||
@@ -1779,7 +1779,16 @@ impl Game<'_> {
|
||||
level: super::Dim,
|
||||
pos: [f64; 3],
|
||||
rotation: Option<[f32; 2]>,
|
||||
) -> bool {
|
||||
turn_riders: bool,
|
||||
) -> R<bool> {
|
||||
match self.teleport_vehicle_across(t, level, pos, rotation, turn_riders) {
|
||||
Ok(true) => return Ok(true),
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
log::error!("entity teleport into {}: {error}", level.id());
|
||||
return Err(err("commands.generic.exception", &[]));
|
||||
}
|
||||
}
|
||||
let conn = match t {
|
||||
Target::Player => self.lan_acting,
|
||||
Target::Guest(conn) => Some(conn),
|
||||
@@ -1787,7 +1796,7 @@ impl Game<'_> {
|
||||
};
|
||||
let at = self.target_level(t);
|
||||
let Some(conn) = conn.filter(|_| at != level) else {
|
||||
return false;
|
||||
return Ok(false);
|
||||
};
|
||||
self.level_teleports
|
||||
.push(super::server_travel::LevelTeleport {
|
||||
@@ -1795,8 +1804,9 @@ impl Game<'_> {
|
||||
dim: level,
|
||||
at: pos,
|
||||
rotation,
|
||||
vehicle: None,
|
||||
});
|
||||
true
|
||||
Ok(true)
|
||||
}
|
||||
/// `ServerPlayer.lookAt`: besides turning the player, tell their client
|
||||
/// with a `ClientboundPlayerLookAtPacket` (`from` anchor, the point, and
|
||||
@@ -1854,13 +1864,22 @@ impl Game<'_> {
|
||||
};
|
||||
if id.ends_with("destination") {
|
||||
let dest = self.entities(ctx, "destination", false)?[0];
|
||||
let pos = self.target_position(dest).unwrap_or(self.source_position());
|
||||
// Into the destination's level.
|
||||
let level = self.target_level(dest);
|
||||
// TeleportCommand.teleportToEntity: the destination's rotation too.
|
||||
let rotation = Some(self.target_rotation(dest).map(wrap_degrees));
|
||||
let (pos, level, rotation, destination_name) = self
|
||||
.in_target_level(dest, |game| {
|
||||
(
|
||||
game.target_position(dest).unwrap_or(game.source_position()),
|
||||
game.target_level(dest),
|
||||
Some(game.target_rotation(dest).map(wrap_degrees)),
|
||||
game.target_name(dest),
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| err("argument.entity.notfound.entity", &[]))?;
|
||||
let target_name = (targets.len() == 1).then(|| {
|
||||
self.in_target_level(targets[0], |game| game.target_name(targets[0]))
|
||||
.unwrap_or_else(Text::empty)
|
||||
});
|
||||
for &t in &targets {
|
||||
if !self.teleport_across(t, level, pos, rotation) {
|
||||
if !self.teleport_across(t, level, pos, rotation, true)? {
|
||||
// TeleportCommand.teleportToEntity: the destination's angles,
|
||||
// which the riders turn to as well.
|
||||
self.teleport_to(t, pos, rotation, true);
|
||||
@@ -1869,15 +1888,12 @@ impl Game<'_> {
|
||||
let message = if targets.len() == 1 {
|
||||
Text::translate(
|
||||
"commands.teleport.success.entity.single",
|
||||
&[self.target_name(targets[0]), self.target_name(dest)],
|
||||
&[target_name.unwrap(), destination_name],
|
||||
)
|
||||
} else {
|
||||
Text::translate(
|
||||
"commands.teleport.success.entity.multiple",
|
||||
&[
|
||||
Text::literal(targets.len().to_string()),
|
||||
self.target_name(dest),
|
||||
],
|
||||
&[Text::literal(targets.len().to_string()), destination_name],
|
||||
)
|
||||
};
|
||||
self.feedback(message);
|
||||
@@ -1940,6 +1956,10 @@ impl Game<'_> {
|
||||
None if self.source_entity().is_none() => super::Dim::Overworld,
|
||||
None => self.dim,
|
||||
};
|
||||
let target_name = (targets.len() == 1).then(|| {
|
||||
self.in_target_level(targets[0], |game| game.target_name(targets[0]))
|
||||
.unwrap_or_else(Text::empty)
|
||||
});
|
||||
for &t in &targets {
|
||||
// `facing` teleports with the rotation unchanged (Relative.ROTATION,
|
||||
// no turn for the riders), then LookAt.perform turns the target alone.
|
||||
@@ -1947,7 +1967,9 @@ impl Game<'_> {
|
||||
let turn_riders = looked.is_none();
|
||||
let rotation = looked.or_else(|| {
|
||||
let (angles, relative) = rotation?;
|
||||
let current = self.target_rotation(t);
|
||||
let current = self
|
||||
.in_target_level(t, |game| game.target_rotation(t))
|
||||
.unwrap_or([0.0, 0.0]);
|
||||
// performTeleport wraps relative deltas before calculateAbsolute
|
||||
// adds them back; wrapping the resolved pitch changes its clamp.
|
||||
Some(std::array::from_fn(|axis| {
|
||||
@@ -1958,7 +1980,7 @@ impl Game<'_> {
|
||||
}
|
||||
}))
|
||||
});
|
||||
if self.teleport_across(t, source_level, pos, rotation) {
|
||||
if self.teleport_across(t, source_level, pos, rotation, turn_riders)? {
|
||||
continue;
|
||||
}
|
||||
self.teleport_to(t, pos, rotation, turn_riders);
|
||||
@@ -1971,7 +1993,7 @@ impl Game<'_> {
|
||||
let message = if targets.len() == 1 {
|
||||
Text::translate(
|
||||
"commands.teleport.success.location.single",
|
||||
&[self.target_name(targets[0]), x, y, z],
|
||||
&[target_name.unwrap(), x, y, z],
|
||||
)
|
||||
} else {
|
||||
Text::translate(
|
||||
@@ -2778,7 +2800,8 @@ impl Game<'_> {
|
||||
entity: &mut horizon_client::containers::BlockEntity,
|
||||
) -> bool {
|
||||
use horizon_client::{containers::BlockEntity, save, structure_blocks};
|
||||
let table = save::text(tag.get("LootTable"));
|
||||
let table = save::text(tag.get("LootTable"))
|
||||
.and_then(|id| horizon_client_core::identifier::normalize_id(&id));
|
||||
let rolled = match (table, entity) {
|
||||
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
|
||||
let loot = structure_blocks::loot_data(&self.options.resources, &self.pack_files());
|
||||
|
||||
@@ -530,7 +530,7 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {
|
||||
let source = self.command_source.unwrap_or_else(|| self.player_source());
|
||||
let view = source.entity.map(|t| self.entity_view_of(t));
|
||||
let view = source.entity.map(|t| self.entity_view_asked(t, cond));
|
||||
self.conditions_hold(cond, view.as_ref(), source.pos)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,43 +67,47 @@ impl Game<'_> {
|
||||
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
|
||||
.flatten()
|
||||
}
|
||||
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
|
||||
// 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
|
||||
/// 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`.
|
||||
fn saved_player_uuid(&self) -> Option<[i32; 4]> {
|
||||
self.lan_acting_uuid
|
||||
.map(horizon_client::lan::playerdata::uuid_ints)
|
||||
.or(self.level.player_uuid);
|
||||
.or(self.level.player_uuid)
|
||||
}
|
||||
/// [`Self::entity_nbt`] of the player whose turn it is.
|
||||
pub(in crate::game) fn player_nbt(&mut self) -> Option<Tag> {
|
||||
self.player.as_ref()?;
|
||||
self.sync_level();
|
||||
let mut tag = self.level.player_tag();
|
||||
if let Some(u) = self.saved_player_uuid() {
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(u.to_vec()));
|
||||
}
|
||||
// NbtPredicate.getEntityTagToCompare: a player's held stack.
|
||||
let selected = self.level.selected_slot;
|
||||
let held = match tag.get("Inventory") {
|
||||
Some(Tag::List(items)) => items
|
||||
.iter()
|
||||
.find(|i| matches!(i.get("Slot"), Some(Tag::Byte(b)) if i32::from(*b) == selected))
|
||||
.cloned(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(mut stack) = held {
|
||||
if let Tag::Compound(c) = &mut stack {
|
||||
c.remove(&"Slot".into());
|
||||
}
|
||||
set_key(&mut tag, "SelectedItem", stack);
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
|
||||
let player_uuid = self.saved_player_uuid();
|
||||
Some(match t {
|
||||
Target::Guest(_) => {
|
||||
return self
|
||||
.for_player(t, |g| g.entity_nbt(Target::Player))
|
||||
.flatten();
|
||||
}
|
||||
Target::Player => {
|
||||
self.player.as_ref()?;
|
||||
self.sync_level();
|
||||
let mut tag = self.level.player_tag();
|
||||
if let Some(u) = player_uuid {
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(u.to_vec()));
|
||||
}
|
||||
// NbtPredicate.getEntityTagToCompare: a player's held stack.
|
||||
let selected = self.level.selected_slot;
|
||||
let held = match tag.get("Inventory") {
|
||||
Some(Tag::List(items)) => items
|
||||
.iter()
|
||||
.find(|i| matches!(i.get("Slot"), Some(Tag::Byte(b)) if i32::from(*b) == selected))
|
||||
.cloned(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(mut stack) = held {
|
||||
if let Tag::Compound(c) = &mut stack {
|
||||
c.remove(&"Slot".into());
|
||||
}
|
||||
set_key(&mut tag, "SelectedItem", stack);
|
||||
}
|
||||
tag
|
||||
}
|
||||
Target::Player => return self.player_nbt(),
|
||||
Target::Mob(id) => {
|
||||
// `saveWithoutId` writes the entity as saved, riders under
|
||||
// `Passengers`: `/data get` reads the same tree persistence does (#1519).
|
||||
@@ -313,7 +317,10 @@ impl Game<'_> {
|
||||
pub(in crate::game) fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
|
||||
let stale = || err("commands.data.merge.failed", &[]);
|
||||
match t {
|
||||
Target::Player => return Err(err("commands.data.entity.invalid", &[])),
|
||||
// EntityDataAccessor.setData: players can't be modified, guests neither.
|
||||
Target::Player | Target::Guest(_) => {
|
||||
return Err(err("commands.data.entity.invalid", &[]));
|
||||
}
|
||||
Target::Mob(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let kind = self
|
||||
@@ -348,6 +355,9 @@ impl Game<'_> {
|
||||
slot.extra.owner = old.extra.owner;
|
||||
}
|
||||
slot.ai = old.ai;
|
||||
// Mob.readAdditionalSaveData updates persistent home fields,
|
||||
// while retaining the rest of the live AI/runtime state.
|
||||
entity_nbt::read_mob_home(slot, &tag);
|
||||
slot.extra.armor_attributes = old.extra.armor_attributes;
|
||||
slot.age = old.age;
|
||||
slot.previous = slot.position;
|
||||
@@ -383,6 +393,45 @@ impl Game<'_> {
|
||||
*slot = new;
|
||||
}
|
||||
}
|
||||
Target::Boat(id) => {
|
||||
// AbstractBoat.readAdditionalSaveData over Entity.load: the
|
||||
// position, rotation, motion, NoGravity and a chest boat's items.
|
||||
let (position, velocity, yaw) =
|
||||
entity_nbt::boat_from_nbt(&tag).ok_or_else(stale)?;
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = self
|
||||
.boats
|
||||
.iter_mut()
|
||||
.find(|b| b.id == id)
|
||||
.ok_or_else(stale)?;
|
||||
b.position = position;
|
||||
b.previous = position;
|
||||
b.velocity = velocity;
|
||||
b.yaw = yaw;
|
||||
b.previous_yaw = yaw;
|
||||
b.pitch = entity_nbt::rotation(&tag)[1];
|
||||
b.no_gravity = entity_nbt::flag(&tag, "NoGravity");
|
||||
if b.chest {
|
||||
b.items = save::decode_items(tag.get("Items"), crate::game::boats::CHEST_SLOTS);
|
||||
}
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
Target::Cart(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let i = self
|
||||
.carts
|
||||
.iter()
|
||||
.position(|c| c.id == id)
|
||||
.ok_or_else(stale)?;
|
||||
let mut cart = self.carts[i].clone();
|
||||
cart.load(&tag).ok_or_else(stale)?;
|
||||
crate::game::minecarts::forget_unreadable_display(&mut cart, |t| {
|
||||
self.state_from_nbt(t).is_some()
|
||||
});
|
||||
self.roll_cart_loot(&mut cart, &tag);
|
||||
self.carts[i] = cart;
|
||||
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
|
||||
}
|
||||
Target::Other(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let marker = self
|
||||
@@ -407,6 +456,8 @@ impl Game<'_> {
|
||||
| Target::Item(id)
|
||||
| Target::Stand(id)
|
||||
| Target::Frame(id)
|
||||
| Target::Boat(id)
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id)
|
||||
| Target::Fangs(id) = t
|
||||
{
|
||||
@@ -508,6 +559,19 @@ impl Game<'_> {
|
||||
let prefix = if role == 0 { "target" } else { "source" };
|
||||
self.data_ref_named(ctx, role, prefix)
|
||||
}
|
||||
/// `PistonMovingBlockEntity.saveAdditional`, with full command metadata.
|
||||
fn moving_piston_data(&self, p: [i32; 3]) -> Option<Tag> {
|
||||
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
|
||||
let (_, piston) = self
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.find(|(at, _)| *at == pos)?;
|
||||
let mut tag = self.moving_piston_to_nbt(pos, piston);
|
||||
// BlockEntity.saveWithoutMetadata includes the component map.
|
||||
set_key(&mut tag, "components", empty());
|
||||
Some(tag)
|
||||
}
|
||||
pub(in crate::game) fn data_ref_named(
|
||||
&mut self,
|
||||
ctx: &Context,
|
||||
@@ -537,6 +601,7 @@ impl Game<'_> {
|
||||
if !self.world.block_entities.contains_key(&(p[0], p[1], p[2]))
|
||||
&& self.comparator_data(p).is_none()
|
||||
&& self.pot_data(p).is_none()
|
||||
&& self.moving_piston_data(p).is_none()
|
||||
{
|
||||
return Err(err("commands.data.block.invalid", &[]));
|
||||
}
|
||||
@@ -561,6 +626,9 @@ impl Game<'_> {
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Block(p) => {
|
||||
if let Some(tag) = self.moving_piston_data(*p) {
|
||||
return Ok(tag);
|
||||
}
|
||||
if let Some(tag) = self.comparator_data(*p).or_else(|| self.pot_data(*p)) {
|
||||
return Ok(tag);
|
||||
}
|
||||
@@ -590,6 +658,11 @@ impl Game<'_> {
|
||||
match r {
|
||||
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
|
||||
DataRef::Block(p) => {
|
||||
// This view is read-only: never decode a live piston into a
|
||||
// second, detached entry in world.block_entities.
|
||||
if self.moving_piston_data(*p).is_some() {
|
||||
return Err(err("commands.data.block.invalid", &[]));
|
||||
}
|
||||
if self.set_pot_data(*p, &tag).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -660,8 +733,25 @@ impl Game<'_> {
|
||||
),
|
||||
}
|
||||
}
|
||||
fn query_text(&self, r: &DataRef, data: &Tag) -> Text {
|
||||
let pretty = snbt::pretty(data);
|
||||
fn query_text(&self, r: &DataRef, data: &Tag, path: Option<&NbtPath>) -> Text {
|
||||
// Only the live piston writer supplies this provenance. The same
|
||||
// compound in storage, an entity, or another block keeps default order.
|
||||
let piston = if let DataRef::Block(p) = r
|
||||
&& self.moving_piston_data(*p).is_some()
|
||||
{
|
||||
match path {
|
||||
None => Some(snbt::PistonPart::Entity),
|
||||
Some(p) if p.is_field_path(&[]) => Some(snbt::PistonPart::Entity),
|
||||
Some(p) if p.is_field_path(&["blockState"]) => Some(snbt::PistonPart::State),
|
||||
Some(p) if p.is_field_path(&["blockState", "properties"]) => {
|
||||
Some(snbt::PistonPart::Properties)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let pretty = piston.map_or_else(|| snbt::pretty(data), |p| snbt::pretty_piston(data, p));
|
||||
match r {
|
||||
DataRef::Entity(_) => {
|
||||
Text::translate("commands.data.entity.query", &[self.data_name(r), pretty])
|
||||
@@ -754,7 +844,7 @@ impl Game<'_> {
|
||||
"data.get" => {
|
||||
let data = self.data_of(&target)?;
|
||||
if ctx.get("path").is_none() {
|
||||
let message = self.query_text(&target, &data);
|
||||
let message = self.query_text(&target, &data, None);
|
||||
self.system_feedback(message);
|
||||
return Ok(1);
|
||||
}
|
||||
@@ -767,7 +857,7 @@ impl Game<'_> {
|
||||
return Ok(value);
|
||||
}
|
||||
let value = data_cmd::get_value(&tag, &path)?;
|
||||
let message = self.query_text(&target, &tag);
|
||||
let message = self.query_text(&target, &tag, Some(&path));
|
||||
self.system_feedback(message);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
@@ -134,12 +134,12 @@ impl Game<'_> {
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.map_or([0.0; 2], |b| [b.yaw, 0.0]),
|
||||
.map_or([0.0; 2], |b| [b.yaw, b.pitch]),
|
||||
Target::Cart(id) => self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map_or([0.0; 2], |c| [c.yaw, 0.0]),
|
||||
.map_or([0.0; 2], |c| [c.yaw, c.pitch]),
|
||||
Target::Fangs(id) => self
|
||||
.fangs
|
||||
.list
|
||||
@@ -685,13 +685,22 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
// Leashable.getLeashHolder: a fence knot is not an entity here.
|
||||
Some("leasher") => match entity {
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player)))
|
||||
// Leashable.getLeashHolder: the server's resolved holder, not a
|
||||
// saved UUID that is still waiting for tickLeash to restore it.
|
||||
Some("leasher") => {
|
||||
let leash =
|
||||
match entity {
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).and_then(|m| {
|
||||
m.extra.leash.map(|holder| (holder, m.extra.leash_player))
|
||||
}),
|
||||
Target::Boat(id) => self
|
||||
.boats
|
||||
.iter()
|
||||
.find(|b| b.id == id)
|
||||
.and_then(|b| b.leash.map(|holder| (holder, b.leash_player))),
|
||||
_ => None,
|
||||
};
|
||||
leash
|
||||
.and_then(|(holder, player)| match holder {
|
||||
// The player who tied it (the host's when unrecorded).
|
||||
horizon_client::mobs::leash::Holder::Player => {
|
||||
@@ -703,9 +712,8 @@ impl Game<'_> {
|
||||
| horizon_client::mobs::leash::Holder::Knot(_) => None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
.collect()
|
||||
}
|
||||
// TraceableEntity.getOwner: who shot a projectile, who threw an
|
||||
// item, who summoned a vex.
|
||||
Some("origin") => match entity {
|
||||
@@ -949,6 +957,17 @@ impl Game<'_> {
|
||||
let keep = results.iter().any(|r| r.is_some_and(|v| (v != 0) == want));
|
||||
Ok((keep, 0))
|
||||
}
|
||||
/// `ExecuteCommand.isChunkLoaded`: an entity-ticking loading status and
|
||||
/// completed entity loading, rather than merely a retained lit column.
|
||||
fn command_chunk_loaded(&self, x: i32, z: i32) -> bool {
|
||||
self.tickets.levels().loaded_entity_ticking(x, z)
|
||||
&& self.world.chunks.contains_key(&(x, z))
|
||||
&& self.streaming.is_lit(x, z)
|
||||
// Streaming queues the saved tags before making the column resident;
|
||||
// spawn_loaded_entities applies them before this chunk's entities run.
|
||||
&& !self.world.loaded_entities.iter().any(|(key, _)| *key == (x, z))
|
||||
}
|
||||
|
||||
fn condition_matches(&mut self, c: &Context) -> R<(bool, i32)> {
|
||||
let flag = |b: bool| (b, i32::from(b));
|
||||
if c.has("data") {
|
||||
@@ -976,12 +995,10 @@ impl Game<'_> {
|
||||
.coords(c, "pos")
|
||||
.map(|co| co.block(self.source_position(), self.source_rotation()));
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
|
||||
let loaded = |p: [i32; 3]| {
|
||||
let key = (p[0] >> 4, p[2] >> 4);
|
||||
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
|
||||
};
|
||||
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
|
||||
return Ok(flag(
|
||||
dimension == self.dim
|
||||
&& p.is_some_and(|p| self.command_chunk_loaded(p[0] >> 4, p[2] >> 4)),
|
||||
));
|
||||
}
|
||||
if c.has("dimension") {
|
||||
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
|
||||
|
||||
@@ -11,6 +11,9 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
mod contents;
|
||||
use contents::Component;
|
||||
|
||||
/// One addressable slot.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(in crate::game) enum Slot {
|
||||
@@ -33,6 +36,9 @@ pub(in crate::game) enum Slot {
|
||||
Block([i32; 3], usize),
|
||||
/// A slot of another player (`for_player`): reached with their state in place.
|
||||
Guest(u32, Box<Slot>),
|
||||
/// A component collection for writes, or an indexed component item for reads.
|
||||
Contents(Box<Slot>, Component),
|
||||
ContentItem(Box<Slot>, Component, usize),
|
||||
}
|
||||
struct SlotCandidate {
|
||||
slot: Slot,
|
||||
@@ -224,6 +230,17 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}),
|
||||
"slot_range" => self.slots_named(owner, source["slots"].as_str().unwrap_or_default()),
|
||||
"contents" => {
|
||||
let Some(component) = source["component"].as_str().and_then(Component::named)
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let parents = self.inline_slots(owner, &source["slot_source"]);
|
||||
parents
|
||||
.into_iter()
|
||||
.flat_map(|p| self.contents_slots(p, component))
|
||||
.collect()
|
||||
}
|
||||
"limit_slots" => {
|
||||
let mut slots = self.inline_slots(owner, &source["slot_source"]);
|
||||
slots.truncate(source["limit"].as_u64().unwrap_or(0) as usize);
|
||||
@@ -307,6 +324,17 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
"contents" => {
|
||||
if let Some(component) = source["component"].as_str().and_then(Component::named) {
|
||||
for parent in self.inline_slots(owner, &source["slot_source"]) {
|
||||
out.push(SlotCandidate {
|
||||
slot: Slot::Contents(Box::new(parent), component),
|
||||
filters: filters.to_vec(),
|
||||
limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"filtered" => {
|
||||
let mut filters = filters.to_vec();
|
||||
filters.push(source["item_filter"].clone());
|
||||
@@ -344,6 +372,11 @@ impl Game<'_> {
|
||||
}
|
||||
pub(in crate::game) fn slot_get(&mut self, s: &Slot) -> Option<ItemStack> {
|
||||
match s {
|
||||
Slot::Contents(..) => None,
|
||||
Slot::ContentItem(parent, component, index) => {
|
||||
let stack = self.slot_get(parent)?;
|
||||
component.items(&stack).get(*index).cloned().flatten()
|
||||
}
|
||||
Slot::Guest(conn, slot) => self
|
||||
.for_player(Target::Guest(*conn), |g| g.slot_get(slot))
|
||||
.flatten(),
|
||||
@@ -476,6 +509,19 @@ impl Game<'_> {
|
||||
/// Put a stack (or nothing) in a slot; false when the slot cannot take it.
|
||||
pub(in crate::game) fn slot_set(&mut self, s: &Slot, stack: Option<ItemStack>) -> bool {
|
||||
match s {
|
||||
Slot::Contents(..) => false,
|
||||
Slot::ContentItem(parent, component, index) => {
|
||||
let Some(mut outer) = self.slot_get(parent) else {
|
||||
return false;
|
||||
};
|
||||
let mut slots = component.items(&outer);
|
||||
let Some(slot) = slots.get_mut(*index) else {
|
||||
return false;
|
||||
};
|
||||
*slot = stack;
|
||||
component.store(&mut outer, slots);
|
||||
self.slot_set(parent, Some(outer))
|
||||
}
|
||||
Slot::Guest(conn, slot) => self
|
||||
.for_player(Target::Guest(*conn), |g| g.slot_set(slot, stack))
|
||||
.unwrap_or(false),
|
||||
@@ -937,7 +983,7 @@ impl Game<'_> {
|
||||
};
|
||||
let modifier = self.item_modifier_arg(ctx, "modifier")?;
|
||||
let source = self.command_source.unwrap_or_else(|| self.player_source());
|
||||
let view = source.entity.map(|t| self.entity_view_of(t));
|
||||
let view = source.entity.map(|t| self.entity_view_asked(t, &modifier));
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos() as i64);
|
||||
@@ -959,6 +1005,29 @@ impl Game<'_> {
|
||||
}
|
||||
let mut changed = 0;
|
||||
for candidate in &slots {
|
||||
if let Slot::Contents(parent, component) = &candidate.slot {
|
||||
changed += self.modify_contents(
|
||||
parent,
|
||||
*component,
|
||||
candidate,
|
||||
&mut selected,
|
||||
|g, current| {
|
||||
g.apply_item_modifier(
|
||||
&modifier,
|
||||
Some(current),
|
||||
&mut rng,
|
||||
view.as_ref(),
|
||||
source.pos,
|
||||
0,
|
||||
)
|
||||
.map(|mut s| {
|
||||
s.count = s.count.min(g.stack_max_size(&s));
|
||||
s
|
||||
})
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !self.select_slot(candidate, &mut selected, true) {
|
||||
continue;
|
||||
}
|
||||
@@ -1080,7 +1149,7 @@ impl Game<'_> {
|
||||
if ctx.get("modifier").is_some() {
|
||||
let modifier = self.item_modifier_arg(ctx, "modifier")?;
|
||||
let source = self.command_source.unwrap_or_else(|| self.player_source());
|
||||
let view = source.entity.map(|t| self.entity_view_of(t));
|
||||
let view = source.entity.map(|t| self.entity_view_asked(t, &modifier));
|
||||
let mut rng = horizon_core::random::LegacyRandom::new(0);
|
||||
found = found
|
||||
.into_iter()
|
||||
@@ -1119,6 +1188,16 @@ impl Game<'_> {
|
||||
let mut changed = 0;
|
||||
let mut i = 0;
|
||||
for candidate in &slots {
|
||||
if let Slot::Contents(parent, component) = &candidate.slot {
|
||||
changed += self.replace_contents(
|
||||
parent,
|
||||
*component,
|
||||
candidate,
|
||||
&mut selected,
|
||||
&mut contents::Provider::new(&items, &mut i, &mode),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !self.select_slot(candidate, &mut selected, false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
//! ContentsSlotSource's live component collections; mutable until written back.
|
||||
use super::{Game, ItemStack, Slot, SlotCandidate};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(in crate::game) enum Component {
|
||||
Container,
|
||||
Bundle,
|
||||
Charged,
|
||||
}
|
||||
pub(super) struct Provider<'a> {
|
||||
items: &'a [Option<ItemStack>],
|
||||
position: &'a mut usize,
|
||||
mode: &'a str,
|
||||
}
|
||||
impl<'a> Provider<'a> {
|
||||
pub(super) fn new(
|
||||
items: &'a [Option<ItemStack>],
|
||||
position: &'a mut usize,
|
||||
mode: &'a str,
|
||||
) -> Self {
|
||||
Self {
|
||||
items,
|
||||
position,
|
||||
mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Component {
|
||||
pub(super) fn named(name: &str) -> Option<Self> {
|
||||
match name.strip_prefix("minecraft:").unwrap_or(name) {
|
||||
"container" => Some(Self::Container),
|
||||
"bundle_contents" => Some(Self::Bundle),
|
||||
"charged_projectiles" => Some(Self::Charged),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn items(self, stack: &ItemStack) -> Vec<Option<ItemStack>> {
|
||||
let Some(data) = stack.data() else {
|
||||
return Vec::new();
|
||||
};
|
||||
match self {
|
||||
Self::Container => {
|
||||
let size = data
|
||||
.container
|
||||
.iter()
|
||||
.map(|(i, _)| usize::from(*i) + 1)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let mut out = vec![None; size];
|
||||
for (i, item) in &data.container {
|
||||
out[usize::from(*i)] = Some(item.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
Self::Bundle => data.bundle_contents.iter().cloned().map(Some).collect(),
|
||||
Self::Charged => data.charged.iter().cloned().map(Some).collect(),
|
||||
}
|
||||
}
|
||||
/// ComponentSlotCollection writes a single normalized component back to
|
||||
/// its live parent; all unrelated parent components remain modeled.
|
||||
pub(super) fn store(self, stack: &mut ItemStack, mut slots: Vec<Option<ItemStack>>) {
|
||||
for slot in &mut slots {
|
||||
if slot
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.count == 0 || &*s.item == "minecraft:air")
|
||||
{
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
if self == Self::Bundle {
|
||||
// BundleContents.Mutable.mergeIdenticalStacks: merge into a later
|
||||
// matching entry, preserving the order of the surviving entries.
|
||||
let mut i = 0;
|
||||
while i < slots.len() {
|
||||
let Some(item) = slots[i].as_ref() else {
|
||||
slots.remove(i);
|
||||
continue;
|
||||
};
|
||||
if let Some(j) = (i + 1..slots.len()).find(|&j| {
|
||||
slots[j]
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.same_item_same_components(item))
|
||||
}) {
|
||||
let count = item.count;
|
||||
slots[j].as_mut().unwrap().count += count;
|
||||
slots.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let data = stack.data_mut();
|
||||
match self {
|
||||
Self::Container => {
|
||||
data.container = slots
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, s)| s.map(|s| (i as u8, s)))
|
||||
.collect()
|
||||
}
|
||||
Self::Bundle => data.bundle_contents = slots.into_iter().flatten().collect(),
|
||||
Self::Charged => data.charged = slots.into_iter().flatten().collect(),
|
||||
}
|
||||
stack.tidy();
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn contents_slots(&mut self, parent: Slot, component: Component) -> Vec<Slot> {
|
||||
let size = self
|
||||
.slot_get(&parent)
|
||||
.map_or(0, |s| component.items(&s).len());
|
||||
(0..size)
|
||||
.map(|i| Slot::ContentItem(Box::new(parent.clone()), component, i))
|
||||
.collect()
|
||||
}
|
||||
fn select_contents_item(
|
||||
&mut self,
|
||||
candidate: &SlotCandidate,
|
||||
selected: &mut usize,
|
||||
stack: Option<&ItemStack>,
|
||||
nonempty: bool,
|
||||
) -> bool {
|
||||
if *selected >= candidate.limit || (nonempty && stack.is_none()) {
|
||||
return false;
|
||||
}
|
||||
let air = ItemStack::new("minecraft:air", 0);
|
||||
if !candidate
|
||||
.filters
|
||||
.iter()
|
||||
.all(|f| self.loot_item_filter(f, stack.unwrap_or(&air)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
*selected += 1;
|
||||
true
|
||||
}
|
||||
fn contents_can_grow(&self, component: Component, slots: &[Option<ItemStack>]) -> bool {
|
||||
match component {
|
||||
Component::Container => slots.len() < 256,
|
||||
Component::Charged => slots.len() < 1024,
|
||||
Component::Bundle => !horizon_client::bundle::Contents::from_items(
|
||||
self.res.items(),
|
||||
slots.iter().flatten().cloned().collect(),
|
||||
)
|
||||
.is_full(),
|
||||
}
|
||||
}
|
||||
fn contents_accepts(
|
||||
&self,
|
||||
component: Component,
|
||||
slots: &[Option<ItemStack>],
|
||||
index: usize,
|
||||
item: Option<&ItemStack>,
|
||||
) -> bool {
|
||||
if component != Component::Bundle {
|
||||
return true;
|
||||
}
|
||||
let Some(item) = item else { return true };
|
||||
if !horizon_client::bundle::can_be_in_bundle(item) {
|
||||
return false;
|
||||
}
|
||||
let rest = slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != index)
|
||||
.filter_map(|(_, s)| s.clone())
|
||||
.collect();
|
||||
horizon_client::bundle::Contents::from_items(self.res.items(), rest)
|
||||
.room_for(self.res.items(), item)
|
||||
>= item.count
|
||||
}
|
||||
/// ComponentSlotCollection.modifySlots: visit occupied slots only, then
|
||||
/// commit the component through the same live parent slot.
|
||||
pub(super) fn modify_contents(
|
||||
&mut self,
|
||||
parent: &Slot,
|
||||
component: Component,
|
||||
candidate: &SlotCandidate,
|
||||
selected: &mut usize,
|
||||
mut modifier: impl FnMut(&mut Self, ItemStack) -> Option<ItemStack>,
|
||||
) -> i32 {
|
||||
let Some(mut stack) = self.slot_get(parent) else {
|
||||
return 0;
|
||||
};
|
||||
let mut slots = component.items(&stack);
|
||||
let mut changed = 0;
|
||||
for i in 0..slots.len() {
|
||||
if !self.select_contents_item(candidate, selected, slots[i].as_ref(), true) {
|
||||
continue;
|
||||
}
|
||||
let Some(current) = slots[i].clone() else {
|
||||
continue;
|
||||
};
|
||||
let next =
|
||||
modifier(self, current).filter(|s| s.count > 0 && &*s.item != "minecraft:air");
|
||||
if self.contents_accepts(component, &slots, i, next.as_ref()) {
|
||||
slots[i] = next;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
component.store(&mut stack, slots);
|
||||
if self.slot_set(parent, Some(stack)) {
|
||||
changed
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
/// GrowableMutableContainer.replaceSlotItems: consume one provider shared
|
||||
/// by the owner's source terms, including new component slots.
|
||||
pub(super) fn replace_contents(
|
||||
&mut self,
|
||||
parent: &Slot,
|
||||
component: Component,
|
||||
candidate: &SlotCandidate,
|
||||
selected: &mut usize,
|
||||
provider: &mut Provider<'_>,
|
||||
) -> i32 {
|
||||
let items = provider.items;
|
||||
let position = &mut *provider.position;
|
||||
let mode = provider.mode;
|
||||
let original = self.slot_get(parent);
|
||||
let mut stack = original
|
||||
.clone()
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
|
||||
let mut slots = component.items(&stack);
|
||||
let mut changed = 0;
|
||||
for i in 0..slots.len() {
|
||||
if !has_next(items, *position, mode) {
|
||||
break;
|
||||
}
|
||||
if !self.select_contents_item(candidate, selected, slots[i].as_ref(), false) {
|
||||
continue;
|
||||
}
|
||||
let next = next_item(items, position, mode);
|
||||
if self.contents_accepts(component, &slots, i, next.as_ref()) {
|
||||
slots[i] = next;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
while has_next(items, *position, mode) && self.contents_can_grow(component, &slots) {
|
||||
if !self.select_contents_item(candidate, selected, None, false) {
|
||||
break;
|
||||
}
|
||||
if component != Component::Container && !find_nonempty(items, position, mode) {
|
||||
break;
|
||||
}
|
||||
let next = peek_item(items, *position, mode);
|
||||
if !self.contents_accepts(component, &slots, slots.len(), next.as_ref()) {
|
||||
break;
|
||||
}
|
||||
slots.push(next_item(items, position, mode));
|
||||
changed += 1;
|
||||
}
|
||||
// ItemStack.EMPTY cannot gain components, though its virtual mutable
|
||||
// collection can still report replacements.
|
||||
if original.is_none() {
|
||||
return changed;
|
||||
}
|
||||
component.store(&mut stack, slots);
|
||||
if self.slot_set(parent, Some(stack)) {
|
||||
changed
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
fn has_next(items: &[Option<ItemStack>], position: usize, mode: &str) -> bool {
|
||||
!items.is_empty() && (mode != "replace" || position < items.len())
|
||||
}
|
||||
fn peek_item(items: &[Option<ItemStack>], position: usize, mode: &str) -> Option<ItemStack> {
|
||||
let index = if mode == "fill" {
|
||||
position % items.len()
|
||||
} else {
|
||||
position
|
||||
};
|
||||
items
|
||||
.get(index)
|
||||
.cloned()
|
||||
.flatten()
|
||||
.filter(|s| s.count > 0 && &*s.item != "minecraft:air")
|
||||
}
|
||||
fn next_item(items: &[Option<ItemStack>], position: &mut usize, mode: &str) -> Option<ItemStack> {
|
||||
let next = peek_item(items, *position, mode);
|
||||
*position += 1;
|
||||
next
|
||||
}
|
||||
fn find_nonempty(items: &[Option<ItemStack>], position: &mut usize, mode: &str) -> bool {
|
||||
// ArrayItemProvider.findNext changes position only when it finds a match.
|
||||
// A failed component append must leave air available to later source terms.
|
||||
for offset in 0..items.len() {
|
||||
let next = *position + offset;
|
||||
if mode != "fill" && next >= items.len() {
|
||||
return false;
|
||||
}
|
||||
if peek_item(items, next, mode).is_some() {
|
||||
*position = next;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
fn failed_component_growth_keeps_air_for_the_next_source_term() {
|
||||
let items = [None];
|
||||
let mut position = 0;
|
||||
assert!(!find_nonempty(&items, &mut position, "replace"));
|
||||
assert_eq!(position, 0);
|
||||
assert!(has_next(&items, position, "replace"));
|
||||
assert!(next_item(&items, &mut position, "replace").is_none());
|
||||
assert_eq!(position, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_storage_keeps_sparse_positions_and_the_live_parent() {
|
||||
let mut parent = ItemStack::new("minecraft:stone", 1);
|
||||
parent.data_mut().other.push((
|
||||
"minecraft:custom_data".into(),
|
||||
Tag::compound([("audit", Tag::Int(7))]),
|
||||
));
|
||||
let mut slots = vec![None; 6];
|
||||
slots[0] = Some(ItemStack::new("minecraft:diamond", 1));
|
||||
slots[5] = Some(ItemStack::new("minecraft:dirt", 7));
|
||||
Component::Container.store(&mut parent, slots.clone());
|
||||
assert_eq!((&*parent.item, parent.count), ("minecraft:stone", 1));
|
||||
assert_eq!(
|
||||
parent.data().unwrap().other("minecraft:custom_data"),
|
||||
Some(&Tag::compound([("audit", Tag::Int(7))]))
|
||||
);
|
||||
assert_eq!(Component::Container.items(&parent), slots);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_writeback_merges_identical_entries_but_keeps_distinct_components() {
|
||||
let mut parent = ItemStack::new("minecraft:bundle", 1);
|
||||
Component::Bundle.store(
|
||||
&mut parent,
|
||||
vec![Some(ItemStack::new("minecraft:gold_ingot", 1)); 64],
|
||||
);
|
||||
assert_eq!(
|
||||
parent.data().unwrap().bundle_contents,
|
||||
vec![ItemStack::new("minecraft:gold_ingot", 64)]
|
||||
);
|
||||
let plain = ItemStack::new("minecraft:gold_ingot", 1);
|
||||
let mut marked = plain.clone();
|
||||
marked.data_mut().other.push((
|
||||
"minecraft:custom_data".into(),
|
||||
Tag::compound([("audit", Tag::Int(7))]),
|
||||
));
|
||||
Component::Bundle.store(&mut parent, vec![Some(plain.clone()), Some(marked.clone())]);
|
||||
assert_eq!(parent.data().unwrap().bundle_contents, vec![plain, marked]);
|
||||
}
|
||||
}
|
||||
@@ -1458,10 +1458,17 @@ impl Game<'_> {
|
||||
if !self.native_may_board(id) {
|
||||
return Err(generic());
|
||||
}
|
||||
self.riding = Some(id);
|
||||
self.yaw = self.boats[i].yaw;
|
||||
// ServerPlayer.startRiding seats the player immediately.
|
||||
let at = self.player_boat_seat(i);
|
||||
if let Some(player) = self.player.as_mut() {
|
||||
player.position = at;
|
||||
player.velocity = horizon_core::geometry::Vec3::ZERO;
|
||||
player.teleported = true;
|
||||
}
|
||||
self.previous_position = at;
|
||||
// sendEventAndTriggers: START_RIDING_TRIGGER.
|
||||
self.trigger_player("minecraft:started_riding");
|
||||
self.start_riding(id);
|
||||
}
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
@@ -1476,8 +1483,7 @@ impl Game<'_> {
|
||||
{
|
||||
return Err(generic());
|
||||
}
|
||||
self.riding = Some(id);
|
||||
self.trigger_player("minecraft:started_riding");
|
||||
self.start_riding(id);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
@@ -1512,9 +1518,9 @@ impl Game<'_> {
|
||||
m.extra.counter = 0;
|
||||
self.yaw = m.yaw;
|
||||
}
|
||||
self.riding = Some(mob_id);
|
||||
self.mount.jump_ticks = 0;
|
||||
self.mount.jump_scale = 0.0;
|
||||
self.start_riding(mob_id);
|
||||
self.feedback(Text::translate(
|
||||
"commands.ride.mount.success",
|
||||
&[name, vehicle_name],
|
||||
@@ -1551,11 +1557,22 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.mobs[i].extra.mount = None;
|
||||
let remaining = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id);
|
||||
if let Some(vehicle) = self.mobs.iter_mut().find(|m| m.id == v) {
|
||||
vehicle.extra.rider = remaining;
|
||||
}
|
||||
let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw);
|
||||
let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) {
|
||||
self.boat_dismount_target(b, width, yaw)
|
||||
} else if let Some(at) = self.horse_dismount_point(v, i) {
|
||||
at
|
||||
} else if let Some(at) = self.cart_dismount_point(v, i) {
|
||||
at
|
||||
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == v) {
|
||||
m.extra.rider = None;
|
||||
// Entity.getDismountLocationForPassenger: on top of the vehicle.
|
||||
let p = m.position;
|
||||
horizon_core::geometry::Vec3::new(p.x, p.y + f64::from(m.height()), p.z)
|
||||
|
||||
@@ -161,6 +161,7 @@ impl Game<'_> {
|
||||
assets: &self.pack.assets,
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
health: &self.health_resources,
|
||||
}
|
||||
.no_player_collision(bounds)
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -303,8 +303,14 @@ impl Game<'_> {
|
||||
if name == "minecraft:shears" && self.golem_shearable(index) {
|
||||
// CopperGolem.shear: the sound, the antenna slot emptied (an
|
||||
// unequip) and the flower dropped 1.5 up.
|
||||
self.audio
|
||||
.play("minecraft:entity.copper_golem.shear", Some(at), 1.0, 1.0);
|
||||
self.play_mob_bound_sound(
|
||||
index,
|
||||
"minecraft:entity.copper_golem.shear",
|
||||
7,
|
||||
1.0,
|
||||
1.0,
|
||||
false,
|
||||
);
|
||||
let antenna = self.mobs[index].saddle_item();
|
||||
self.mobs[index].set_saddle(None);
|
||||
let id = self.mobs[index].id;
|
||||
@@ -331,8 +337,7 @@ impl Game<'_> {
|
||||
}
|
||||
if is_axe && waxed {
|
||||
self.mobs[index].extra.vector[0] = -1.0;
|
||||
self.audio
|
||||
.play("minecraft:item.axe.scrape", Some(at), 1.0, 1.0);
|
||||
self.play_mob_bound_sound(index, "minecraft:item.axe.scrape", 6, 1.0, 1.0, false);
|
||||
self.level_event_at(3004, block, 0);
|
||||
self.damage_held_tool();
|
||||
return true;
|
||||
@@ -341,8 +346,7 @@ impl Game<'_> {
|
||||
let m = &mut self.mobs[index];
|
||||
m.extra.vector[0] = -1.0;
|
||||
m.extra.variant = horizon_client::mobs::copper_golem::weather_previous(m.extra.variant);
|
||||
self.audio
|
||||
.play("minecraft:item.axe.scrape", Some(at), 1.0, 1.0);
|
||||
self.play_mob_bound_sound(index, "minecraft:item.axe.scrape", 6, 1.0, 1.0, false);
|
||||
self.level_event_at(3005, block, 0);
|
||||
self.damage_held_tool();
|
||||
return true;
|
||||
|
||||
@@ -566,11 +566,10 @@ impl Game<'_> {
|
||||
let amount = ((damage / 4.0) as u32).max(1);
|
||||
let name = damage_type(kind, false);
|
||||
let fire = self
|
||||
.motion
|
||||
.health
|
||||
.health_resources
|
||||
.damage
|
||||
.id(name)
|
||||
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
|
||||
.is_some_and(|d| self.health_resources.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
|
||||
@@ -1152,7 +1151,9 @@ impl Game<'_> {
|
||||
..base
|
||||
};
|
||||
w.controlling_player_movement = controlling_player_movement;
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
let kept =
|
||||
horizon_client::mobs::riders::tick(&mut mobs, i, &w, &mut scratch, &mut events);
|
||||
let m = &mut mobs[i];
|
||||
if seen_guest.is_none() {
|
||||
base.player = w.player;
|
||||
}
|
||||
@@ -1489,15 +1490,10 @@ impl Game<'_> {
|
||||
pub(super) fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
|
||||
let source = match d.attacker {
|
||||
// Its callers are KilledTrigger's: the source as their killing_blow sees it.
|
||||
Some(Who::Player) => Some(
|
||||
self.player_entity_looking(
|
||||
self.adv.tree.may_look("minecraft:player_killed_entity")
|
||||
|| self
|
||||
.adv
|
||||
.tree
|
||||
.may_look("minecraft:kill_mob_near_sculk_catalyst"),
|
||||
),
|
||||
),
|
||||
Some(Who::Player) => Some(self.player_entity_for_any(&[
|
||||
"minecraft:player_killed_entity",
|
||||
"minecraft:kill_mob_near_sculk_catalyst",
|
||||
])),
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -1851,8 +1847,8 @@ impl Game<'_> {
|
||||
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
|
||||
/// sequence with the mob as `this` (a chicken's egg depends on its
|
||||
/// variant); the loot data of the game's resources decides, and without it
|
||||
/// the items' table engine (no entity) rolls it.
|
||||
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) {
|
||||
/// the items' table engine (no entity) rolls it. Whether anything dropped.
|
||||
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) -> bool {
|
||||
use horizon_client::mobs::drops;
|
||||
use horizon_world::loot::EntityLootParams;
|
||||
let loot = horizon_client::structure_blocks::loot_data(
|
||||
@@ -1888,11 +1884,13 @@ impl Game<'_> {
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random),
|
||||
};
|
||||
let dropped = !stacks.is_empty();
|
||||
for d in stacks {
|
||||
// The ItemEntity constructor's motion, from the entity's own
|
||||
// random (not the level's).
|
||||
self.spawn_item(d, at, dispenser::new_item_motion(), 10);
|
||||
}
|
||||
dropped
|
||||
}
|
||||
/// Adds a projectile, giving it an id from the entity counter
|
||||
/// (`Entity.id`, the same counter as the rest) when it has none.
|
||||
@@ -2046,6 +2044,29 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, speed, count);
|
||||
}
|
||||
MobEvent::EntitySound {
|
||||
mob,
|
||||
name,
|
||||
at,
|
||||
volume,
|
||||
pitch,
|
||||
source,
|
||||
} => {
|
||||
let silent = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == mob)
|
||||
.is_some_and(|m| m.silent);
|
||||
let entity = horizon_client::sound_engine::SoundEntity::new(mob, at, silent);
|
||||
self.audio.play_bound(
|
||||
&format!("minecraft:{name}"),
|
||||
source,
|
||||
volume,
|
||||
pitch,
|
||||
entity,
|
||||
None,
|
||||
);
|
||||
}
|
||||
MobEvent::Sound(name, at, volume, pitch)
|
||||
| MobEvent::LocalSound(name, at, volume, pitch) => {
|
||||
self.audio
|
||||
@@ -2968,7 +2989,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
MobEvent::StartRiding { rider, vehicle } => {
|
||||
horizon_client::mobs::riders::start_riding(&mut self.mobs, rider, vehicle);
|
||||
if horizon_client::mobs::riders::start_riding(&mut self.mobs, rider, vehicle) {
|
||||
// sendEventAndTriggers: the server players already on it.
|
||||
self.note_started_riding(vehicle);
|
||||
}
|
||||
}
|
||||
// Applied after the fish's tick (`apply_brain_events`).
|
||||
MobEvent::StartFollowing { .. } | MobEvent::SchoolSize { .. } => {}
|
||||
@@ -3055,6 +3079,16 @@ impl Game<'_> {
|
||||
let ctx = self.who_context(Some(Who::Mob(mob)), None);
|
||||
self.post_game_event("minecraft:entity_place", at, ctx);
|
||||
}
|
||||
MobEvent::LayEgg { mob, at, pitch } => {
|
||||
// Chicken.aiStep: `dropFromGiftLootTable` says whether
|
||||
// anything dropped; only then the sound and the game event.
|
||||
if self.drop_gift_loot(mob, "minecraft:gameplay/chicken_lay", at) {
|
||||
self.audio
|
||||
.play("minecraft:entity.chicken.egg", Some(at), 1.0, pitch);
|
||||
let ctx = self.who_context(Some(Who::Mob(mob)), None);
|
||||
self.post_game_event("minecraft:entity_place", at, ctx);
|
||||
}
|
||||
}
|
||||
MobEvent::GiftLoot { mob, table, at } => {
|
||||
// Turtle.ageBoundaryReached and Panda.afterSneeze: only
|
||||
// with mob loot on.
|
||||
@@ -3257,6 +3291,8 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| parent.clone());
|
||||
let mut child = parent.clone();
|
||||
child.baby = true;
|
||||
// Not born yet: none of the parent's saved data is its own.
|
||||
child.nbt = None;
|
||||
// TurtleBreedGoal.breed triggers it without a child.
|
||||
let turtle = matches!(p.species(), Species::Turtle | Species::Frog);
|
||||
if matches!(
|
||||
@@ -3631,9 +3667,7 @@ impl Game<'_> {
|
||||
// A projectile entity dealt it: `#is_projectile`, or a firework
|
||||
// rocket (its `fireworks` type is not in the tag).
|
||||
let projectile = rocket
|
||||
|| self
|
||||
.res
|
||||
.stations()
|
||||
|| world_stations!(self)
|
||||
.enchantments
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
@@ -3688,9 +3722,9 @@ impl Game<'_> {
|
||||
// runs `applyItemBlocking` before `dealDefaultKnockback`).
|
||||
let mut default_push: Option<([f64; 2], HurtSource<'_>)> = None;
|
||||
if let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
&& let Some(id) = self.health_resources.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_caused_by(
|
||||
&self.motion.health,
|
||||
&self.health_resources,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
@@ -3831,9 +3865,9 @@ impl Game<'_> {
|
||||
let mut canceled = false;
|
||||
if !self.creative
|
||||
&& let Some(player) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
&& let Some(id) = self.health_resources.damage.id(damage_type)
|
||||
&& let Ok(o) = player.hurt_blocking(
|
||||
&self.motion.health,
|
||||
&self.health_resources,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
@@ -3916,7 +3950,7 @@ impl Game<'_> {
|
||||
}
|
||||
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
|
||||
let mut hits = Vec::new();
|
||||
let mut deflections: Vec<[f64; 3]> = Vec::new();
|
||||
let mut deflections: Vec<u32> = Vec::new();
|
||||
let mut arrow_events = Vec::new();
|
||||
let mut shoot_events = Vec::new();
|
||||
let mut trails = Vec::new();
|
||||
@@ -4094,8 +4128,7 @@ impl Game<'_> {
|
||||
);
|
||||
// Entity.deflection: a breeze turns every projectile but wind charges away.
|
||||
if let Hit::Entity {
|
||||
who: Who::Mob(id),
|
||||
at,
|
||||
who: Who::Mob(id), ..
|
||||
} = hit
|
||||
&& !wind
|
||||
&& breezes.contains(&id)
|
||||
@@ -4106,7 +4139,7 @@ impl Game<'_> {
|
||||
let a = p.arrow.as_mut().expect("an arrow");
|
||||
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
|
||||
}
|
||||
deflections.push([at.x, at.y, at.z]);
|
||||
deflections.push(id);
|
||||
continue;
|
||||
}
|
||||
// AbstractWindCharge.tick: out of the world's top it bursts.
|
||||
@@ -4191,9 +4224,17 @@ impl Game<'_> {
|
||||
for pos in bubble_tops {
|
||||
self.bubble_column_particles(pos);
|
||||
}
|
||||
for at in deflections {
|
||||
self.audio
|
||||
.play("minecraft:entity.breeze.deflect", Some(at), 1.0, 1.0);
|
||||
for id in deflections {
|
||||
if let Some(index) = self.mobs.iter().position(|m| m.id == id) {
|
||||
self.play_mob_bound_sound(
|
||||
index,
|
||||
"minecraft:entity.breeze.deflect",
|
||||
5,
|
||||
1.0,
|
||||
1.0,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (at, event) in arrow_events {
|
||||
self.arrow_event(at, event);
|
||||
@@ -5632,6 +5673,12 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if !self.mobs[index].kind.food.contains(&item)
|
||||
&& self.interact_sheep_dye(index, self.hand, false)
|
||||
{
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if self.interact_allay(index, held.as_ref()) {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
@@ -5801,6 +5848,14 @@ impl Game<'_> {
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
f64::from(m.height()),
|
||||
));
|
||||
self.audio.play_bound(
|
||||
"minecraft:entity.mooshroom.shear",
|
||||
7,
|
||||
1.0,
|
||||
1.0,
|
||||
horizon_client::sound_engine::SoundEntity::new(m.id, feet, m.silent),
|
||||
None,
|
||||
);
|
||||
m.removed = true;
|
||||
let setup = SpawnSetup {
|
||||
yaw: m.yaw,
|
||||
@@ -5808,7 +5863,6 @@ impl Game<'_> {
|
||||
..Default::default()
|
||||
};
|
||||
let pos = [m.position.x, m.position.y, m.position.z];
|
||||
sound = Some("entity.mooshroom.shear");
|
||||
self.handle_mob_events(vec![MobEvent::SpawnKind {
|
||||
kind: "minecraft:cow".into(),
|
||||
at: pos,
|
||||
@@ -5830,6 +5884,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
|
||||
self.audio.play_bound(
|
||||
"minecraft:entity.sheep.shear",
|
||||
7,
|
||||
1.0,
|
||||
1.0,
|
||||
horizon_client::sound_engine::SoundEntity::new(m.id, feet, m.silent),
|
||||
None,
|
||||
);
|
||||
m.extra.sheared = true;
|
||||
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
|
||||
drops_table = format!("minecraft:shearing/sheep/{color}");
|
||||
@@ -5841,26 +5903,8 @@ impl Game<'_> {
|
||||
);
|
||||
drops.extend(stacks.into_iter().map(|s| (s.item.to_string(), s.count)));
|
||||
wool = true;
|
||||
sound = Some("entity.sheep.shear");
|
||||
self.damage_held_tool();
|
||||
}
|
||||
Species::Sheep if item.ends_with("_dye") => {
|
||||
let name = item
|
||||
.trim_start_matches("minecraft:")
|
||||
.trim_end_matches("_dye");
|
||||
if let Some(c) = horizon_client::mobs::render::DYE_NAMES
|
||||
.iter()
|
||||
.position(|d| *d == name)
|
||||
&& m.extra.color as usize != c
|
||||
&& !m.extra.sheared
|
||||
{
|
||||
m.extra.color = c as u8;
|
||||
consume = true;
|
||||
sound = Some("item.dye.use");
|
||||
} else {
|
||||
handled = false;
|
||||
}
|
||||
}
|
||||
// Entity.interact: shears take a tamed wolf's armor off for its owner.
|
||||
Species::Wolf
|
||||
if item == "minecraft:shears"
|
||||
@@ -6308,8 +6352,15 @@ impl Game<'_> {
|
||||
.play(&format!("minecraft:{s}"), Some(center), 1.0, pitch);
|
||||
}
|
||||
if let Some((s, volume, pitch)) = eat {
|
||||
self.audio
|
||||
.play(&format!("minecraft:{s}"), Some(feet), volume, pitch);
|
||||
let event = format!("minecraft:{s}");
|
||||
if matches!(
|
||||
self.mobs[index].species(),
|
||||
Species::Frog | Species::Goat | Species::Sniffer
|
||||
) {
|
||||
self.play_mob_bound_sound(index, &event, 6, volume, pitch, false);
|
||||
} else {
|
||||
self.audio.play(&event, Some(feet), volume, pitch);
|
||||
}
|
||||
}
|
||||
if consume
|
||||
&& !creative
|
||||
@@ -6417,7 +6468,7 @@ impl Game<'_> {
|
||||
let at = m.position;
|
||||
let mob = (m.id, super::native_hooks::mob_kind(m));
|
||||
let tool = self.held().cloned();
|
||||
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
|
||||
self.play_mob_bound_sound(index, sound, 7, 1.0, 1.0, false);
|
||||
self.mobs[index].extra.sheared = true;
|
||||
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
|
||||
let origin = [at.x, at.y, at.z];
|
||||
@@ -7206,8 +7257,10 @@ impl Game<'_> {
|
||||
if let Some((start, rgb)) = glowing.take() {
|
||||
self.mark_outline(&out[start..], rgb);
|
||||
}
|
||||
if a.id != 0 && self.entity_glowing(a.id) {
|
||||
glowing = Some((out.len(), super::glowing::team_color(self.mob_team(a.id))));
|
||||
if a.id != 0
|
||||
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
|
||||
{
|
||||
glowing = Some((out.len(), rgb));
|
||||
}
|
||||
let p = glam::DVec3::from_array(at) - camera;
|
||||
let c = p.as_vec3();
|
||||
|
||||
@@ -468,8 +468,8 @@ mod tests {
|
||||
let shot = game.audio.capture.take().unwrap_or_default();
|
||||
let at: Vec<_> = shot
|
||||
.iter()
|
||||
.filter(|s| s.0 == "minecraft:entity.arrow.shoot")
|
||||
.map(|s| s.1)
|
||||
.filter(|s| s.event == "minecraft:entity.arrow.shoot")
|
||||
.map(|s| s.position)
|
||||
.collect();
|
||||
assert_eq!(at, vec![feet]);
|
||||
|
||||
@@ -481,8 +481,8 @@ mod tests {
|
||||
let shot = game.audio.capture.take().unwrap_or_default();
|
||||
let at: Vec<_> = shot
|
||||
.iter()
|
||||
.filter(|s| s.0 == "minecraft:item.crossbow.shoot")
|
||||
.map(|s| s.1)
|
||||
.filter(|s| s.event == "minecraft:item.crossbow.shoot")
|
||||
.map(|s| s.position)
|
||||
.collect();
|
||||
assert_eq!(at, vec![feet]);
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ impl Game<'_> {
|
||||
if self.riding.is_some() {
|
||||
return true;
|
||||
}
|
||||
self.riding = Some(id);
|
||||
self.start_riding(id);
|
||||
let p = self.cushions[i].position;
|
||||
self.audio.play(
|
||||
"minecraft:entity.cushion.sit",
|
||||
|
||||
@@ -62,6 +62,22 @@ pub(super) fn record_events(events: &[horizon_world::level::LevelEvent]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `S` line for a level event whose sound `LevelEventHandler` plays on the
|
||||
/// client (`playLocalSound`), so Horizon plays it unshared and never captures
|
||||
/// it: the lava fizz (1501, #7652) and `ComposterBlock.handleFill` (1500,
|
||||
/// `data > 0` = success, #9069).
|
||||
fn event_sound(line: &str) -> Option<String> {
|
||||
let mut parts = line.strip_prefix("L ")?.split(' ');
|
||||
let (id, pos, data) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
let sound = match id {
|
||||
"1501" => "minecraft:block.lava.extinguish",
|
||||
"1500" if data.parse::<i32>().ok()? > 0 => "minecraft:block.composter.fill_success",
|
||||
"1500" => "minecraft:block.composter.fill",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("S {pos} {sound}"))
|
||||
}
|
||||
|
||||
/// Java's `String.format("%.6f")`: the exact value rounded half up (away
|
||||
/// from zero) where Rust rounds a tie to even.
|
||||
fn num(v: f64) -> String {
|
||||
@@ -431,18 +447,11 @@ impl Game<'_> {
|
||||
out.push(self.fixture_describe(i, t));
|
||||
}
|
||||
let log = LOG.with(|log| log.borrow_mut().take()).unwrap_or_default();
|
||||
// LevelEventHandler's lava fizz (1501): the client plays it from the
|
||||
// level event, so it is not captured as a shared sound (#7652).
|
||||
let fizzes: Vec<String> = log
|
||||
.iter()
|
||||
.filter_map(|line| line.strip_prefix("L 1501 "))
|
||||
.filter_map(|rest| rest.split(' ').next())
|
||||
.map(|pos| format!("S {pos} minecraft:block.lava.extinguish"))
|
||||
.collect();
|
||||
let event_sounds: Vec<String> = log.iter().filter_map(|l| event_sound(l)).collect();
|
||||
out.extend(log);
|
||||
out.extend(fizzes);
|
||||
for (sound, at, _, _) in self.audio.capture.take().unwrap_or_default() {
|
||||
out.push(format!("S {} {sound}", floor_pos(at)));
|
||||
out.extend(event_sounds);
|
||||
for sound in self.audio.capture.take().unwrap_or_default() {
|
||||
out.push(format!("S {} {}", floor_pos(sound.position), sound.event));
|
||||
}
|
||||
out.extend(used);
|
||||
out.push(format!("rng {}", self.world.random.next_long()));
|
||||
|
||||
@@ -111,6 +111,12 @@ pub(super) struct Fight {
|
||||
needs_scan: bool,
|
||||
pub killed: bool,
|
||||
pub previously_killed: bool,
|
||||
/// `dragonUUID`: the fight's own dragon. Only its death wins the fight
|
||||
/// (`setDragonKilled`); a dragon summoned after the fight is won is not it.
|
||||
dragon_uuid: Option<[i32; 4]>,
|
||||
/// `ticksSinceDragonSeen`: ticks since the fight's dragon last ticked
|
||||
/// (`updateDragon`); at 1200 the fight looks for another one.
|
||||
dragon_unseen: i32,
|
||||
gateways: Vec<i32>,
|
||||
exit_portal: Option<[i32; 3]>,
|
||||
alive_crystals: i32,
|
||||
@@ -838,6 +844,10 @@ impl Game<'_> {
|
||||
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
|
||||
_ => None,
|
||||
},
|
||||
dragon_uuid: match data.and_then(|d| d.get("dragon_uuid")) {
|
||||
Some(Tag::IntArray(v)) if v.len() == 4 => Some([v[0], v[1], v[2], v[3]]),
|
||||
_ => None,
|
||||
},
|
||||
// The first tick scans for players.
|
||||
player_scan: 21,
|
||||
..Default::default()
|
||||
@@ -889,6 +899,9 @@ impl Game<'_> {
|
||||
if let Some(p) = f.exit_portal {
|
||||
data.push(("exit_portal_location", Tag::IntArray(p.to_vec())));
|
||||
}
|
||||
if let Some(u) = f.dragon_uuid {
|
||||
data.push(("dragon_uuid", Tag::IntArray(u.to_vec())));
|
||||
}
|
||||
if let Some((stage, _)) = f.respawn {
|
||||
data.push(("respawn_stage", Tag::String(stage.name().into())));
|
||||
data.push((
|
||||
@@ -1021,11 +1034,7 @@ impl Game<'_> {
|
||||
}
|
||||
if self.fight.players {
|
||||
if self.fight.needs_scan {
|
||||
// scanState: a missing exit portal means a fight in progress.
|
||||
self.fight.previously_killed = self.end_portal_active();
|
||||
if !self.fight.previously_killed {
|
||||
self.fight.killed = false;
|
||||
}
|
||||
self.scan_state();
|
||||
self.fight.needs_scan = false;
|
||||
self.fight.dirty = true;
|
||||
}
|
||||
@@ -1033,8 +1042,19 @@ impl Game<'_> {
|
||||
self.tick_respawn();
|
||||
}
|
||||
if !self.fight.killed {
|
||||
if self.dragon.is_none() {
|
||||
self.create_new_dragon();
|
||||
// Without a dragon of its own, or 1200 ticks after it last
|
||||
// ticked, the fight takes the End's dragon or makes one
|
||||
// (`findOrCreateDragon`).
|
||||
let look = match self.fight.dragon_uuid {
|
||||
None => true,
|
||||
Some(_) => {
|
||||
self.fight.dragon_unseen += 1;
|
||||
self.fight.dragon_unseen >= 1200
|
||||
}
|
||||
};
|
||||
if look {
|
||||
self.find_or_create_dragon();
|
||||
self.fight.dragon_unseen = 0;
|
||||
}
|
||||
self.fight.crystal_scan += 1;
|
||||
if self.fight.crystal_scan >= 100 {
|
||||
@@ -1056,6 +1076,12 @@ impl Game<'_> {
|
||||
self.next_entity += 1;
|
||||
c.id = self.next_entity;
|
||||
}
|
||||
// updateDragon, from the fight's dragon's aiStep (not with NoAI) and
|
||||
// tickDeath: it has been seen.
|
||||
let ticks = self.dragon.as_ref().is_some_and(|d| !d.alive() || !d.no_ai);
|
||||
if ticks && self.is_fight_dragon() {
|
||||
self.fight.dragon_unseen = 0;
|
||||
}
|
||||
self.tick_dragon();
|
||||
}
|
||||
/// EndSpikeFeature's crystals (with fire under them on the bedrock).
|
||||
@@ -2389,6 +2415,16 @@ impl Game<'_> {
|
||||
d.in_wall = in_wall;
|
||||
}
|
||||
}
|
||||
/// `Entity.isSilent` of the dragon: its `Silent`, kept with the fields
|
||||
/// Horizon models nowhere (`remember_tags`: `/summon`, a load).
|
||||
fn dragon_silent(&self) -> bool {
|
||||
let raw = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.filter(|d| d.id != 0)
|
||||
.and_then(|d| self.persist.raw.get(&d.id));
|
||||
saved_silent(raw)
|
||||
}
|
||||
/// `tickDeath`: rising for 200 ticks, explosions, experience, then the
|
||||
/// exit portal, the egg and a gateway.
|
||||
fn tick_dragon_death(&mut self) {
|
||||
@@ -2414,18 +2450,19 @@ impl Game<'_> {
|
||||
[0.0; 3],
|
||||
);
|
||||
}
|
||||
// ServerLevel.getDragonFight: only the End has a fight; elsewhere
|
||||
// (`/summon`) a dragon is worth 500 and its death ends nothing.
|
||||
let fight = self.dim == Dim::End;
|
||||
// Only the fight's own dragon has a fight (`dragonFight`): elsewhere
|
||||
// (`/summon`), or summoned in the End after the fight is won, a
|
||||
// dragon is worth 500 and its death ends nothing.
|
||||
let fight = self.is_fight_dragon();
|
||||
let first_kill = fight && !self.fight.previously_killed;
|
||||
let xp = if first_kill { 12000 } else { 500 };
|
||||
let drops = self.ui.rules.bool("mob_drops");
|
||||
if t > 150 && t % 5 == 0 && drops {
|
||||
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.08) as u32);
|
||||
}
|
||||
if t == 1 {
|
||||
if t == 1 && !self.dragon_silent() {
|
||||
// EnderDragon.tickDeath: global level event 1028, the death
|
||||
// roar, heard everywhere.
|
||||
// roar, heard everywhere, unless the dragon is silent.
|
||||
let at = [p.x, p.y, p.z].map(|v| v.floor() as i32);
|
||||
self.global_level_event(1028, at, 0);
|
||||
}
|
||||
@@ -2715,7 +2752,7 @@ impl Game<'_> {
|
||||
if hurts {
|
||||
self.note_dragon_attacker("minecraft:mob_attack");
|
||||
}
|
||||
let (health, rules) = (&self.motion.health, self.world.health_rules);
|
||||
let (health, rules) = (&self.health_resources, self.world.health_rules);
|
||||
let id = health.damage.id("minecraft:mob_attack");
|
||||
let mut canceled = false;
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
@@ -3254,6 +3291,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Entity.isSilent` from an entity's saved compound: `Silent`, written only
|
||||
/// when set.
|
||||
fn saved_silent(raw: Option<&Tag>) -> bool {
|
||||
matches!(raw.and_then(|t| t.get("Silent")), Some(Tag::Byte(b)) if *b != 0)
|
||||
}
|
||||
/// Saving and loading the dragon and the crystals as chunk entities.
|
||||
impl Dragon {
|
||||
pub(super) fn to_nbt(&self, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
@@ -3550,9 +3592,10 @@ impl Game<'_> {
|
||||
/// drops it, and a killed dragon never gets there.
|
||||
pub(super) fn kill_dragon(&mut self, id: u32) {
|
||||
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
|
||||
// Only the fight's own dragon (`dragonFight`) ends it.
|
||||
let fight = self.is_fight_dragon();
|
||||
self.dragon = None;
|
||||
// ServerLevel.getDragonFight: only the End has a fight.
|
||||
if self.dim == Dim::End {
|
||||
if fight {
|
||||
self.dragon_killed();
|
||||
}
|
||||
}
|
||||
@@ -3847,10 +3890,67 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `EnderDragonFight.createNewDragon`: over the podium, holding pattern, a random yaw.
|
||||
/// It becomes the fight's dragon (`dragonUUID = dragon.getUUID()`).
|
||||
fn create_new_dragon(&mut self) {
|
||||
let yaw = self.world.random.next_float() * 360.0;
|
||||
let seed = self.world.random.next_long();
|
||||
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
|
||||
let mut d = Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed);
|
||||
// EnderDragon's constructor takes its id and its parts' from the
|
||||
// entity counter.
|
||||
d.id = self.next_entity + 1;
|
||||
self.next_entity += 9;
|
||||
let id = d.id;
|
||||
self.dragon = Some(d);
|
||||
self.fight.dragon_uuid = Some(self.uuid_for(id));
|
||||
self.fight.dirty = true;
|
||||
}
|
||||
/// `EnderDragonFight.findOrCreateDragon`: the End's dragon becomes the
|
||||
/// fight's, or a new one is made.
|
||||
fn find_or_create_dragon(&mut self) {
|
||||
match self.dragon.as_ref().map(|d| (d.id, d.alive())) {
|
||||
None => self.create_new_dragon(),
|
||||
Some((id, true)) => {
|
||||
self.fight.dragon_uuid = Some(self.uuid_for(id));
|
||||
self.fight.dirty = true;
|
||||
}
|
||||
// `ServerLevel.getDragons` takes only living dragons, so vanilla
|
||||
// makes a new one beside a dying one; with one dragon to a level,
|
||||
// the new one comes once the dying one is gone.
|
||||
Some((_, false)) => {}
|
||||
}
|
||||
}
|
||||
/// `EnderDragonFight.scanState`, for a world from before the fight was
|
||||
/// saved: an active exit portal means the dragon was killed before. A
|
||||
/// living dragon there is the fight's, unless there is no active portal:
|
||||
/// then it is removed for a new one.
|
||||
fn scan_state(&mut self) {
|
||||
let active = self.end_portal_active();
|
||||
self.fight.previously_killed = active;
|
||||
match self.dragon.as_ref().filter(|d| d.alive()).map(|d| d.id) {
|
||||
None => self.fight.killed = true,
|
||||
Some(id) => {
|
||||
self.fight.dragon_uuid = Some(self.uuid_for(id));
|
||||
self.fight.killed = false;
|
||||
if !active {
|
||||
self.dragon = None;
|
||||
self.fight.dragon_uuid = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.fight.previously_killed && self.fight.killed {
|
||||
self.fight.killed = false;
|
||||
}
|
||||
}
|
||||
/// The dragon is the End fight's own (`EnderDragon.dragonFight`, set
|
||||
/// only for the dragon whose UUID is the fight's `dragonUUID`).
|
||||
fn is_fight_dragon(&mut self) -> bool {
|
||||
if self.dim != Dim::End {
|
||||
return false;
|
||||
}
|
||||
match (self.dragon.as_ref().map(|d| d.id), self.fight.dragon_uuid) {
|
||||
(Some(id), Some(uuid)) if id != 0 => self.uuid_for(id) == uuid,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// The END stage: the portal crystals blow up (no block damage) and a
|
||||
/// new dragon takes over.
|
||||
@@ -4344,6 +4444,24 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// `EnderDragon.tickDeath` roars (`globalLevelEvent(1028)`) only when
|
||||
/// `!isSilent()`; `Silent` is saved only when set, as a byte.
|
||||
#[test]
|
||||
fn a_dragon_is_silent_only_with_a_set_silent_byte() {
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
let with = |v: Option<i8>| {
|
||||
let mut map = Compound::new();
|
||||
if let Some(b) = v {
|
||||
map.insert("Silent".into(), Tag::Byte(b));
|
||||
}
|
||||
Tag::Compound(map)
|
||||
};
|
||||
assert!(!super::saved_silent(None));
|
||||
assert!(!super::saved_silent(Some(&with(None))));
|
||||
assert!(!super::saved_silent(Some(&with(Some(0)))));
|
||||
assert!(super::saved_silent(Some(&with(Some(1)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breath_lifetime_excludes_the_wait() {
|
||||
assert!(!breath_expired(219, 200));
|
||||
|
||||
@@ -68,6 +68,8 @@ enum Arriving {
|
||||
Saved(Tag),
|
||||
/// A thrown ender pearl (projectiles are not saved).
|
||||
Pearl(Box<Projectile>),
|
||||
/// A vehicle remains in this level while its destination loads.
|
||||
Vehicle(u32),
|
||||
}
|
||||
|
||||
impl GatewayJob {
|
||||
@@ -1361,6 +1363,32 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.filter(|m| self.mob_can_use_portal(m))
|
||||
.map(|m| (m.id, m.position, m.width(), m.height()))
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| self.vehicle_can_use_portal(b.id))
|
||||
.map(|b| {
|
||||
(
|
||||
b.id,
|
||||
b.position,
|
||||
super::boats::WIDTH as f32,
|
||||
super::boats::HEIGHT as f32,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.chain(
|
||||
self.carts
|
||||
.iter()
|
||||
.filter(|c| self.vehicle_can_use_portal(c.id))
|
||||
.map(|c| {
|
||||
(
|
||||
c.id,
|
||||
c.position,
|
||||
super::minecarts::WIDTH as f32,
|
||||
super::minecarts::HEIGHT as f32,
|
||||
)
|
||||
}),
|
||||
)
|
||||
.collect();
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
for (id, to, width, height) in items.into_iter().chain(mobs) {
|
||||
@@ -1428,6 +1456,16 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|e| (e.id, true))
|
||||
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.map(|b| (b.id, self.vehicle_can_use_portal(b.id))),
|
||||
)
|
||||
.chain(
|
||||
self.carts
|
||||
.iter()
|
||||
.map(|c| (c.id, self.vehicle_can_use_portal(c.id))),
|
||||
)
|
||||
.chain(
|
||||
self.creatures
|
||||
.projectiles
|
||||
@@ -1440,6 +1478,7 @@ impl Game<'_> {
|
||||
// A pearl waiting for its exit keeps its portal cooldown.
|
||||
present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller {
|
||||
Arriving::Pearl(p) => Some(p.id),
|
||||
Arriving::Vehicle(id) => Some(*id),
|
||||
Arriving::Saved(_) => None,
|
||||
}));
|
||||
self.end.gateways.retain(|id, _| present.contains(id));
|
||||
@@ -1462,8 +1501,27 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// `Entity.teleportToPortalDestination` for an entity that goes through an
|
||||
/// End gateway: it is saved and waits for the exit's chunks.
|
||||
/// End gateway: a same-level vehicle stays live while the exit loads.
|
||||
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
|
||||
if matches!(
|
||||
self.level_entity(id as i32),
|
||||
Some(
|
||||
super::commands::Target::Mob(_)
|
||||
| super::commands::Target::Boat(_)
|
||||
| super::commands::Target::Cart(_)
|
||||
)
|
||||
) {
|
||||
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
|
||||
let arrival = GatewayArrival {
|
||||
traveller: Arriving::Vehicle(id),
|
||||
job,
|
||||
target,
|
||||
};
|
||||
if let Some(waiting) = self.arrive_through_gateway(arrival) {
|
||||
self.end.arrivals.push(waiting);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
|
||||
@@ -1477,31 +1535,6 @@ impl Game<'_> {
|
||||
self.native_leave_skip(id);
|
||||
self.detach_entity(id);
|
||||
tag
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
let mut tag = self.mob_tree_nbt(i);
|
||||
// Entity.removeAfterChangingDimensions drops the leash.
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.remove(&"leash".into());
|
||||
}
|
||||
// The mobs riding it travel with it (their tags are in its compound).
|
||||
let mut gone = vec![id];
|
||||
let mut k = 0;
|
||||
while k < gone.len() {
|
||||
let v = gone[k];
|
||||
gone.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(v))
|
||||
.map(|m| m.id),
|
||||
);
|
||||
k += 1;
|
||||
}
|
||||
self.mobs.retain(|m| !gone.contains(&m.id));
|
||||
for g in gone {
|
||||
self.native_leave_skip(g);
|
||||
self.detach_entity(g);
|
||||
}
|
||||
tag
|
||||
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
|
||||
let pearl = Box::new(self.creatures.projectiles.remove(i));
|
||||
self.native_leave_skip(id);
|
||||
@@ -1564,6 +1597,15 @@ impl Game<'_> {
|
||||
};
|
||||
let key = chunk(at);
|
||||
match arrival.traveller {
|
||||
Arriving::Vehicle(id) => {
|
||||
if let Some(target) = self.level_entity(id as i32) {
|
||||
// Entity.teleportSameDimension keeps the live entity and
|
||||
// its motion, and moves every passenger in the same operation.
|
||||
self.teleport_to_in_level(target, [at.x, at.y, at.z], None, false);
|
||||
self.set_portal_cooldown(id);
|
||||
self.place_portal_ticket(key);
|
||||
}
|
||||
}
|
||||
Arriving::Saved(mut tag) => {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! The entity overload of level sounds, shared by mob and player call sites.
|
||||
use super::Game;
|
||||
use horizon_client::sound_engine::{SoundEntity, SoundEntityState};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
fn saved_silent(extra: Option<&Tag>) -> bool {
|
||||
horizon_client::save::number(extra.and_then(|tag| tag.get("Silent"))).unwrap_or(0) != 0
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Level.playSound(except, mob, ...)`: the category and actor exclusion
|
||||
/// belong to the call site, not to the event's name.
|
||||
pub(super) fn play_mob_bound_sound(
|
||||
&mut self,
|
||||
index: usize,
|
||||
event: &str,
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
except_actor: bool,
|
||||
) {
|
||||
let mob = &self.mobs[index];
|
||||
let entity = horizon_client::sound_engine::SoundEntity::new(
|
||||
mob.id,
|
||||
[mob.position.x, mob.position.y, mob.position.z],
|
||||
mob.silent,
|
||||
);
|
||||
let except = except_actor.then(|| self.native_acting_entity());
|
||||
self.audio
|
||||
.play_bound(event, source, volume, pitch, entity, except);
|
||||
}
|
||||
|
||||
fn sound_player_id(&self) -> u32 {
|
||||
self.remote
|
||||
.as_ref()
|
||||
.map_or_else(|| self.native_acting_entity(), |r| r.entity_id as u32)
|
||||
}
|
||||
|
||||
/// The player in place can be a guest while `as_guest` runs. Its entity
|
||||
/// id and saved metadata travel with that action, even while the hub is out.
|
||||
pub(super) fn play_player_bound_sound(
|
||||
&mut self,
|
||||
event: &str,
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
except_actor: bool,
|
||||
) {
|
||||
let id = self.sound_player_id();
|
||||
let Some(state) = self.sound_entity_state(id) else {
|
||||
return;
|
||||
};
|
||||
let entity = SoundEntity::new(id, state.position, state.silent);
|
||||
entity.update(state);
|
||||
self.audio.play_bound(
|
||||
event,
|
||||
source,
|
||||
volume,
|
||||
pitch,
|
||||
entity,
|
||||
except_actor.then_some(id),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn sound_entity_state(&self, id: u32) -> Option<SoundEntityState> {
|
||||
if self.remote.is_some() {
|
||||
return self.remote_sound_entity_state(id as i32);
|
||||
}
|
||||
if id == self.sound_player_id()
|
||||
&& let Some(player) = self.player.as_ref()
|
||||
{
|
||||
return Some(SoundEntityState {
|
||||
position: [player.position.x, player.position.y, player.position.z],
|
||||
silent: saved_silent(self.level.player_extra.as_ref()),
|
||||
removed: false,
|
||||
});
|
||||
}
|
||||
if let Some(guest) = self.lan.as_ref().and_then(|host| {
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id as u32 == id && g.placed && g.dimension == self.dim.id())
|
||||
}) {
|
||||
return Some(SoundEntityState {
|
||||
position: guest.position,
|
||||
silent: saved_silent(guest.saved.as_ref()),
|
||||
removed: false,
|
||||
});
|
||||
}
|
||||
if let Some(t) = self.tridents.iter().find(|t| t.id == id) {
|
||||
return Some(SoundEntityState {
|
||||
position: [t.position.x, t.position.y, t.position.z],
|
||||
silent: false,
|
||||
removed: false,
|
||||
});
|
||||
}
|
||||
self.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map(|m| SoundEntityState {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
silent: m.silent,
|
||||
removed: m.removed,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -454,14 +454,14 @@ impl Game<'_> {
|
||||
self.note_named_attacker(damage_type, *id, name.clone());
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
&& let Some(id) = self.health_resources.damage.id(damage_type)
|
||||
{
|
||||
// Creative players are invulnerable to explosions.
|
||||
if !creative
|
||||
&& !spectator
|
||||
&& hurtable
|
||||
&& let Ok(o) = p.hurt_caused_by(
|
||||
&self.motion.health,
|
||||
&self.health_resources,
|
||||
self.world.health_rules,
|
||||
id,
|
||||
amount,
|
||||
|
||||
@@ -62,6 +62,7 @@ impl Game<'_> {
|
||||
let now = self.extras_now();
|
||||
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
|
||||
self.native_frame(elapsed);
|
||||
self.pvp_frame();
|
||||
self.recipes_received_now();
|
||||
}
|
||||
/// The frame counter for mods: fps, min, 1% low.
|
||||
@@ -259,13 +260,14 @@ impl Game<'_> {
|
||||
};
|
||||
extras_hud::draw_minimap(batch, atlas, &mut corners, &look);
|
||||
}
|
||||
self.draw_pvp_hud(batch, &mut corners);
|
||||
self.native_hud(batch, &mut corners);
|
||||
}
|
||||
/// The height the status effect icons take at the top right
|
||||
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
|
||||
fn effect_rows_height(&self) -> f32 {
|
||||
let effects = &self.potions.effects.list;
|
||||
if effects.is_empty() || self.screen != Screen::Playing {
|
||||
if effects.is_empty() || self.screen != Screen::Playing || self.pvp_effects_shown() {
|
||||
return 0.0;
|
||||
}
|
||||
let harmful = effects.iter().any(|e| {
|
||||
|
||||
@@ -197,9 +197,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
amount
|
||||
};
|
||||
let hurt = match (self.player.as_mut(), self.motion.health.damage.id(kind)) {
|
||||
let hurt = match (self.player.as_mut(), self.health_resources.damage.id(kind)) {
|
||||
(Some(p), Some(id)) => p
|
||||
.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.hurt(&self.health_resources, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -119,7 +119,7 @@ pub(super) fn overlay_files(
|
||||
for (path, bytes) in contributed {
|
||||
// One bad file would fail its whole table and drop the world's pack
|
||||
// content with it, so it is skipped alone: every kind is JSON.
|
||||
if let Err(e) = serde_json::from_slice::<serde_json::Value>(&bytes) {
|
||||
if let Err(e) = serde_json::from_slice::<Box<serde_json::value::RawValue>>(&bytes) {
|
||||
log::warn!("Horizon: a native mod's data pack file {path} is not JSON, skipped: {e}");
|
||||
continue;
|
||||
}
|
||||
@@ -999,7 +999,7 @@ impl Game<'_> {
|
||||
.ok_or_else(|| err("argument.predicate.unknown", &[&id]))?
|
||||
};
|
||||
let source = self.command_source.unwrap_or_else(|| self.player_source());
|
||||
let view = source.entity.map(|t| self.entity_view_of(t));
|
||||
let view = source.entity.map(|t| self.entity_view_asked(t, &json));
|
||||
Ok(self.conditions_hold(&json, view.as_ref(), source.pos))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,13 +99,12 @@ impl Game<'_> {
|
||||
// timeout.
|
||||
self.mobs[index].extra.counter = 0;
|
||||
self.yaw = self.mobs[index].yaw;
|
||||
self.riding = Some(id);
|
||||
self.mount.reset_jump();
|
||||
if was_empty {
|
||||
let down = happy_ghast::goggles(true, &self.mobs[index]);
|
||||
let _ = self.handle_mob_events(vec![down]);
|
||||
}
|
||||
self.trigger_player("minecraft:started_riding");
|
||||
self.start_riding(id);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
@@ -116,7 +115,7 @@ impl Game<'_> {
|
||||
// aboard the ghast forgets its home.
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.extra.counter = 10;
|
||||
m.ai.home = None;
|
||||
m.ai.clear_home();
|
||||
}
|
||||
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
|
||||
return;
|
||||
@@ -164,9 +163,18 @@ impl Game<'_> {
|
||||
m.yaw = yaw;
|
||||
m.body_yaw = yaw;
|
||||
m.head_yaw = yaw;
|
||||
let at = m.center();
|
||||
self.audio
|
||||
.play("minecraft:entity.ghastling.spawn", Some(at), 1.0, 1.0);
|
||||
self.audio.play_bound(
|
||||
"minecraft:entity.ghastling.spawn",
|
||||
4,
|
||||
1.0,
|
||||
1.0,
|
||||
horizon_client::sound_engine::SoundEntity::new(
|
||||
m.id,
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
m.silent,
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,20 +22,36 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The outline an entity is drawn with: vanilla's (`Some` team colour when it glows),
|
||||
/// then what mods made of it (`native_entity_outline`).
|
||||
pub(super) fn entity_outline(&self, id: u32, kind: &str, vanilla: Option<u32>) -> Option<u32> {
|
||||
let mut outline = vanilla;
|
||||
self.native_entity_outline(id, kind, &mut outline);
|
||||
outline.map(|rgb| rgb & 0xFF_FFFF)
|
||||
}
|
||||
|
||||
/// The outline of a dropped item or projectile: its `Glowing` flag, in its team's colour.
|
||||
pub(super) fn thing_outline(&self, id: u32, kind: &str) -> Option<u32> {
|
||||
let vanilla = self
|
||||
.entity_glowing(id)
|
||||
.then(|| team_color(self.mob_team(id)));
|
||||
self.entity_outline(id, kind, vanilla)
|
||||
}
|
||||
|
||||
/// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline
|
||||
/// colour (`getTeamColor`; another player's team goes by name).
|
||||
pub(super) fn mob_outline(&self, m: &Mob) -> Option<u32> {
|
||||
if !m.is_currently_glowing() {
|
||||
return None;
|
||||
}
|
||||
let team = if &*m.kind.id == "minecraft:player" {
|
||||
let names = self.remote_player_names();
|
||||
let name = names.into_iter().find(|(id, _)| *id == m.id);
|
||||
name.and_then(|(_, name)| self.score.board.players_team(&name))
|
||||
} else {
|
||||
self.mob_team_of(m)
|
||||
};
|
||||
Some(team_color(team))
|
||||
let vanilla = m.is_currently_glowing().then(|| {
|
||||
let team = if &*m.kind.id == "minecraft:player" {
|
||||
let names = self.remote_player_names();
|
||||
let name = names.into_iter().find(|(id, _)| *id == m.id);
|
||||
name.and_then(|(_, name)| self.score.board.players_team(&name))
|
||||
} else {
|
||||
self.mob_team_of(m)
|
||||
};
|
||||
team_color(team)
|
||||
});
|
||||
self.entity_outline(m.id, &m.kind.id, vanilla)
|
||||
}
|
||||
|
||||
/// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures'
|
||||
|
||||
Loaded 100 of 441 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user