mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge origin/main into mods/native-entity-join; API number as a fragment, review fixes
- The hook asks for its API number with tools/native-hook-fragments/entity_join_level.json (#5161); NATIVE_HOOKS_API, the histories and native-hooks.json are main's again. - A mod canceling an item's join no longer crashes a throw: spawn_item's callers touch the new item only if it joined (just_spawned_item), not whatever item is last (throw_from_eyes unwrapped it; allays, shearing and /give's overflow edited an unrelated item). - An End gateway keeps its traveller in the same level (teleportSameDimension), so the item doesn't join again on arrival (arismoko's review). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
94027c278e
320 files changed
+21195
-2150
No files matched your search
@@ -21,6 +21,7 @@
|
||||
.DS_Store
|
||||
# What the client writes to its working directory when run from the repo root.
|
||||
/options.txt
|
||||
/debug-profile.json
|
||||
/horizon.txt
|
||||
/horizon-graphics.txt
|
||||
/horizon-controller.txt
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Combat: attacks vanilla's client can't send (docs/anticheat.md).
|
||||
//!
|
||||
//! Vanilla's client sends ServerboundAttackPacket from one place only, `Minecraft.startAttack`, run from
|
||||
//! `handleKeybinds` once per client tick. It attacks the entity of the tick's `hitResult`, which
|
||||
//! `Minecraft.tick` picks once before the keys are handled, so every attack of one client tick is on the
|
||||
//! same entity. And each attack swings the arm (ServerboundPunchPacket) in the same call, so a client
|
||||
//! tick never has more attacks than swings: a tick that does comes from a kill aura hiding its swing.
|
||||
//! The swing is counted before or after the attack, as older clients behind a version proxy swing
|
||||
//! first; a swing without an attack (a miss, a block) is fine.
|
||||
|
||||
/// What breaks vanilla's way of attacking.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Foul {
|
||||
/// A client tick with more attacks than swings: how many more.
|
||||
NoSwing(u32),
|
||||
/// A second entity attacked in the same client tick: the first one and this one.
|
||||
TwoTargets(i32, i32),
|
||||
}
|
||||
|
||||
/// The attacks of the client tick being heard.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Attacks {
|
||||
/// The entity this tick attacked first.
|
||||
target: Option<i32>,
|
||||
attacks: u32,
|
||||
swings: u32,
|
||||
}
|
||||
|
||||
impl Attacks {
|
||||
/// ServerboundAttackPacket on entity `target`.
|
||||
pub fn attack(&mut self, target: i32) -> Option<Foul> {
|
||||
self.attacks = self.attacks.saturating_add(1);
|
||||
let first = *self.target.get_or_insert(target);
|
||||
(first != target).then_some(Foul::TwoTargets(first, target))
|
||||
}
|
||||
|
||||
/// ServerboundPunchPacket: the arm swung.
|
||||
pub fn swing(&mut self) {
|
||||
self.swings = self.swings.saturating_add(1);
|
||||
}
|
||||
|
||||
/// ServerboundClientTickEndPacket: the tick's attacks are counted, and the next tick picks its
|
||||
/// target again.
|
||||
pub fn tick_end(&mut self) -> Option<Foul> {
|
||||
let unswung = self.attacks.saturating_sub(self.swings);
|
||||
*self = Self::default();
|
||||
(unswung > 0).then_some(Foul::NoSwing(unswung))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_vanilla_tick_is_clean() {
|
||||
let mut a = Attacks::default();
|
||||
// Two clicks in one tick on the same entity, each swung for.
|
||||
for _ in 0..2 {
|
||||
assert_eq!(a.attack(7), None);
|
||||
a.swing();
|
||||
}
|
||||
assert_eq!(a.tick_end(), None);
|
||||
// The next tick may attack another entity, swinging first (an older client).
|
||||
a.swing();
|
||||
assert_eq!(a.attack(8), None);
|
||||
assert_eq!(a.tick_end(), None);
|
||||
// A swing on its own (a miss, a block) is fine.
|
||||
a.swing();
|
||||
assert_eq!(a.tick_end(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attack_not_swung_for_is_caught() {
|
||||
let mut a = Attacks::default();
|
||||
a.attack(7);
|
||||
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
|
||||
// A swing in the tick before doesn't count for this one.
|
||||
a.swing();
|
||||
a.tick_end();
|
||||
a.attack(7);
|
||||
a.attack(7);
|
||||
a.swing();
|
||||
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_entities_in_one_tick_are_caught() {
|
||||
let mut a = Attacks::default();
|
||||
a.attack(7);
|
||||
a.swing();
|
||||
assert_eq!(a.attack(8), Some(Foul::TwoTargets(7, 8)));
|
||||
a.swing();
|
||||
// Still against the tick's first target.
|
||||
assert_eq!(a.attack(9), Some(Foul::TwoTargets(7, 9)));
|
||||
a.swing();
|
||||
assert_eq!(a.tick_end(), None);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,21 @@
|
||||
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
|
||||
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
|
||||
//!
|
||||
//! It is off unless the owner turns it on. A check that fails *flags* the player: its violation level
|
||||
//! (VL) rises by the flag's weight and falls back by `decay` a second. From `setback` on, the move or
|
||||
//! action is cancelled (the player is sent back where the server last had them); from `alert` on,
|
||||
//! online operators and the console are told; from `kick` on, the player is disconnected. Nothing bans.
|
||||
//! Any threshold at 0 turns that action off.
|
||||
//! It is on by default, and so is every check, but only the checks a vanilla client can't trip act on
|
||||
//! their own (`combat`); the others log only until they have shown no false positive with real
|
||||
//! players (their `setback`, `alert` and `kick` are 0). `enabled = false` turns the anticheat, or one
|
||||
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
|
||||
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
|
||||
//! player is sent back where the server last had them); from `alert` on, online operators and the
|
||||
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
|
||||
//! that action off.
|
||||
//!
|
||||
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
|
||||
//! do, never from another anticheat's code.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
pub mod prediction;
|
||||
pub mod timer;
|
||||
|
||||
@@ -72,7 +76,7 @@ macro_rules! checks {
|
||||
}
|
||||
)*
|
||||
|
||||
/// `server.toml`'s `[anticheat]`: off by default.
|
||||
/// `server.toml`'s `[anticheat]`: on by default.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
@@ -89,7 +93,7 @@ macro_rules! checks {
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
alerts: true,
|
||||
alert_level: 2,
|
||||
log: "logs/anticheat.log".into(),
|
||||
@@ -120,7 +124,8 @@ checks! {
|
||||
/// Timer: more client ticks than the server's clock allows (a sped-up game). Vanilla's client ends
|
||||
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
|
||||
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
|
||||
timer: Timer => TimerConfig [2.0, 5.0, 20.0, 0.02] {
|
||||
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
|
||||
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
|
||||
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
|
||||
tolerance_ms: f64 = 300.0,
|
||||
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
|
||||
@@ -129,9 +134,10 @@ checks! {
|
||||
}
|
||||
|
||||
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
|
||||
/// the ground to skip fall damage) and walking on water. The claim is always dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back.
|
||||
ground: Ground => GroundConfig [5.0, 15.0, 60.0, 2.0] {}
|
||||
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
|
||||
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
|
||||
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
@@ -141,11 +147,18 @@ checks! {
|
||||
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
|
||||
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
|
||||
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
|
||||
/// until the moves agree again.
|
||||
prediction: Prediction => PredictionConfig [5.0, 15.0, 60.0, 2.0] {
|
||||
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
|
||||
/// `kick = 60`.
|
||||
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
|
||||
/// How far, in blocks, a move may land from where the replay puts it.
|
||||
tolerance: f64 = 0.03,
|
||||
}
|
||||
|
||||
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
|
||||
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
|
||||
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
|
||||
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
|
||||
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
|
||||
}
|
||||
|
||||
/// How many checks there are.
|
||||
@@ -219,6 +232,8 @@ pub struct Player {
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
@@ -262,12 +277,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_by_default_and_without_a_section() {
|
||||
assert!(!Config::default().enabled);
|
||||
fn on_by_default_but_only_combat_acts() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(!c.runs(Check::Timer));
|
||||
assert!(!c.runs(Check::Ground));
|
||||
for &check in Check::ALL {
|
||||
assert!(c.runs(check), "{}", check.name());
|
||||
let a = c.actions(check);
|
||||
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
|
||||
assert_eq!(acts, check == Check::Combat, "{}", check.name());
|
||||
}
|
||||
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
|
||||
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,6 +16,7 @@ use std::{
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
mod component_predicate;
|
||||
mod special;
|
||||
pub use special::{SpecialKind, SpecialModel};
|
||||
|
||||
@@ -628,7 +629,7 @@ pub enum ConditionProperty {
|
||||
ExtendedView,
|
||||
KeybindDown(String),
|
||||
ViewEntity,
|
||||
/// A `component` predicate, evaluated by the host.
|
||||
/// A single `component` predicate; external predicate types use the host.
|
||||
Component(Value),
|
||||
}
|
||||
impl ConditionProperty {
|
||||
@@ -651,13 +652,7 @@ impl ConditionProperty {
|
||||
"minecraft:extended_view" => Self::ExtendedView,
|
||||
"minecraft:keybind_down" => Self::KeybindDown(string(required(m, "keybind")?)?),
|
||||
"minecraft:view_entity" => Self::ViewEntity,
|
||||
"minecraft:component" => {
|
||||
let predicate = required(m, "predicate")?;
|
||||
if object(predicate)?.len() != 1 {
|
||||
return Err(err("a single component predicate is expected"));
|
||||
}
|
||||
Self::Component(predicate.clone())
|
||||
}
|
||||
"minecraft:component" => Self::Component(component_predicate::decode(m)?),
|
||||
_ => return Err(err("unknown condition property")),
|
||||
})
|
||||
}
|
||||
@@ -964,10 +959,14 @@ pub trait ItemEnv {
|
||||
fn bundle_fullness(&self, _stack: &StackView, _components: &Components) -> f32 {
|
||||
0.0
|
||||
}
|
||||
/// `ItemStack.getMaxStackSize` when the prototype has no component.
|
||||
/// Additional component predicate types supplied by the host.
|
||||
fn component_matches(&self, _stack: &StackView, _predicate: &Value) -> bool {
|
||||
false
|
||||
}
|
||||
/// Membership in the level's enchantment registry tags.
|
||||
fn enchantment_in_tag(&self, _enchantment: &str, _tag: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn is_keybind_down(&self, _keybind: &str) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -1335,7 +1334,8 @@ impl Resolve<'_> {
|
||||
ConditionProperty::KeybindDown(key) => self.env.is_keybind_down(key),
|
||||
ConditionProperty::ViewEntity => owner.is_some_and(|o| o.is_view_entity),
|
||||
ConditionProperty::Component(predicate) => {
|
||||
self.env.component_matches(self.stack, predicate)
|
||||
component_predicate::matches(predicate, self.components, self.env)
|
||||
.unwrap_or_else(|| self.env.component_matches(self.stack, predicate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! Component conditions used by enchantment-dependent item models.
|
||||
use super::*;
|
||||
|
||||
/// `DataComponentPredicate.singleCodec`: a single predicate's type discriminator
|
||||
/// (`predicate`) and its wrapped codec's separate `value` field.
|
||||
pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
|
||||
let id = component_type(required(m, "predicate")?)?;
|
||||
let mut value = required(m, "value")?.clone();
|
||||
if matches!(
|
||||
id.as_str(),
|
||||
"minecraft:enchantments" | "minecraft:stored_enchantments"
|
||||
) {
|
||||
let mut entries = Vec::new();
|
||||
for entry in list(&value)? {
|
||||
let mut entry = object(entry)?.clone();
|
||||
if let Some(selector) = get(&entry, "enchantments") {
|
||||
let selector = match selector {
|
||||
Value::Array(ids) => Value::Array(
|
||||
ids.iter()
|
||||
.map(ident)
|
||||
.map(|r| r.map(Value::String))
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
Value::String(name) if name.starts_with('#') => {
|
||||
Value::String(format!("#{}", identifier(&name[1..])?))
|
||||
}
|
||||
_ => Value::String(ident(selector)?),
|
||||
};
|
||||
entry.insert("enchantments".into(), selector);
|
||||
}
|
||||
if let Some(levels) = get(&entry, "levels") {
|
||||
let levels = if levels.is_object() {
|
||||
let mut bounds = Map::new();
|
||||
for key in ["min", "max"] {
|
||||
if let Some(n) = get(object(levels)?, key) {
|
||||
bounds.insert(key.into(), Value::from(int(n)?));
|
||||
}
|
||||
}
|
||||
// MinMaxBounds.Bounds.validateSwappedBoundsInCodec.
|
||||
if bounds
|
||||
.get("min")
|
||||
.and_then(Value::as_i64)
|
||||
.zip(bounds.get("max").and_then(Value::as_i64))
|
||||
.is_some_and(|(min, max)| min > max)
|
||||
{
|
||||
return Err(err("minimum exceeds maximum"));
|
||||
}
|
||||
Value::Object(bounds)
|
||||
} else {
|
||||
Value::from(int(levels)?)
|
||||
};
|
||||
entry.insert("levels".into(), levels);
|
||||
}
|
||||
entries.push(Value::Object(entry));
|
||||
}
|
||||
value = Value::Array(entries);
|
||||
}
|
||||
Ok(Value::Object(Map::from_iter([(id, value)])))
|
||||
}
|
||||
|
||||
/// `EnchantmentsPredicate.matches` and `EnchantmentPredicate.containedIn`:
|
||||
/// all entries must match; each entry needs one selected, present enchantment
|
||||
/// with a matching level. An empty predicate list still needs the component.
|
||||
pub(super) fn matches(
|
||||
predicate: &Value,
|
||||
components: &Components,
|
||||
env: &dyn ItemEnv,
|
||||
) -> Option<bool> {
|
||||
let (id, expected) = predicate.as_object()?.iter().next()?;
|
||||
if !matches!(
|
||||
id.as_str(),
|
||||
"minecraft:enchantments" | "minecraft:stored_enchantments"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let Some(have) = components.get(id).and_then(Value::as_object) else {
|
||||
return Some(false);
|
||||
};
|
||||
Some(expected.as_array()?.iter().all(|entry| {
|
||||
let Some(entry) = entry.as_object() else {
|
||||
return false;
|
||||
};
|
||||
have.iter().any(|(id, level)| {
|
||||
let level = level.as_i64().unwrap_or(0);
|
||||
let selector = get(entry, "enchantments");
|
||||
let selected = match selector {
|
||||
None => true,
|
||||
Some(Value::String(s)) => s
|
||||
.strip_prefix('#')
|
||||
.map_or(s == id, |tag| env.enchantment_in_tag(id, tag)),
|
||||
Some(Value::Array(ids)) => ids.iter().any(|s| s.as_str() == Some(id)),
|
||||
_ => false,
|
||||
};
|
||||
selected
|
||||
&& if selector.is_some() {
|
||||
matches_enchantment(level, get(entry, "levels"))
|
||||
} else {
|
||||
level_matches(level, get(entry, "levels"))
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/// `EnchantmentPredicate.matchesEnchantment`: a named enchantment needs a
|
||||
/// nonzero level even when its bounds accept zero.
|
||||
fn matches_enchantment(level: i64, bounds: Option<&Value>) -> bool {
|
||||
level != 0 && level_matches(level, bounds)
|
||||
}
|
||||
|
||||
/// `MinMaxBounds.Ints.matches`: inclusive bounds, or an exact integer.
|
||||
fn level_matches(level: i64, bounds: Option<&Value>) -> bool {
|
||||
match bounds {
|
||||
None => true,
|
||||
Some(Value::Number(n)) => n.as_i64() == Some(level),
|
||||
Some(Value::Object(bounds)) => {
|
||||
get(bounds, "min")
|
||||
.and_then(Value::as_i64)
|
||||
.is_none_or(|min| level >= min)
|
||||
&& get(bounds, "max")
|
||||
.and_then(Value::as_i64)
|
||||
.is_none_or(|max| level <= max)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
struct Tags;
|
||||
impl ItemEnv for Tags {
|
||||
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
|
||||
enchantment == "minecraft:density" && tag == "horizon:mace"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unnamed_predicates_inspect_entries_but_named_ones_require_a_nonzero_level() {
|
||||
let components = Components::from_iter([(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":0}),
|
||||
)]);
|
||||
assert_eq!(
|
||||
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
matches(
|
||||
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
|
||||
&components,
|
||||
&NoEnv
|
||||
),
|
||||
Some(false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_selectors_use_the_hosts_registry_tags() {
|
||||
let condition = json!({"predicate":"enchantments", "value":[
|
||||
{"enchantments":"#horizon:mace", "levels":{"min":3}}
|
||||
]});
|
||||
let predicate = decode(condition.as_object().unwrap()).unwrap();
|
||||
let mut components = Components::from_iter([(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":3}),
|
||||
)]);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
|
||||
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
|
||||
components.insert(
|
||||
"minecraft:enchantments".into(),
|
||||
json!({"minecraft:density":2}),
|
||||
);
|
||||
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
|
||||
}
|
||||
}
|
||||
@@ -335,6 +335,177 @@ pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
|
||||
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
|
||||
}
|
||||
|
||||
/// A JOML `Matrix3f`: `m[column][row]`.
|
||||
type Mat3 = [[f32; 3]; 3];
|
||||
|
||||
/// `Matrix3f.mul`: `a * b`.
|
||||
fn mul3(a: &Mat3, b: &Mat3) -> Mat3 {
|
||||
std::array::from_fn(|c| {
|
||||
std::array::from_fn(|r| fma(a[0][r], b[c][0], fma(a[1][r], b[c][1], a[2][r] * b[c][2])))
|
||||
})
|
||||
}
|
||||
/// `Matrix3f.transpose`.
|
||||
fn transpose3(m: &Mat3) -> Mat3 {
|
||||
std::array::from_fn(|c| std::array::from_fn(|r| m[r][c]))
|
||||
}
|
||||
/// `Matrix3f.rotate(Quaternionfc)`: `m * R(q)`, without fma.
|
||||
fn rotate3(m: &Mat3, q: [f32; 4]) -> Mat3 {
|
||||
let [x, y, z, w] = q;
|
||||
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
|
||||
let (zw, xy, xz, yw, yz, xw) = (z * w, x * y, x * z, y * w, y * z, x * w);
|
||||
let (zw, xy, xz, yw, yz, xw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
|
||||
let rotation = [
|
||||
[w2 + x2 - z2 - y2, xy + zw, xz - yw],
|
||||
[xy - zw, y2 - z2 + w2 - x2, yz + xw],
|
||||
[yw + xz, yz - xw, z2 - y2 - x2 + w2],
|
||||
];
|
||||
std::array::from_fn(|c| {
|
||||
let rc = rotation[c];
|
||||
std::array::from_fn(|r| m[0][r] * rc[0] + m[1][r] * rc[1] + m[2][r] * rc[2])
|
||||
})
|
||||
}
|
||||
/// `Quaternionf.mul`: `a * b`.
|
||||
fn quaternion_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
|
||||
let ([x, y, z, w], [qx, qy, qz, qw]) = (a, b);
|
||||
[
|
||||
fma(w, qx, fma(x, qw, fma(y, qz, -z * qy))),
|
||||
fma(w, qy, fma(-x, qz, fma(y, qw, z * qx))),
|
||||
fma(w, qz, fma(x, qy, fma(-y, qx, z * qw))),
|
||||
fma(w, qw, fma(-x, qx, fma(-y, qy, -z * qz))),
|
||||
]
|
||||
}
|
||||
|
||||
/// `GivensParameters`: the half angle's sine and cosine.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Givens(f32, f32);
|
||||
|
||||
impl Givens {
|
||||
/// `GivensParameters.fromUnnormalized`.
|
||||
fn unnormalized(sin_half: f32, cos_half: f32) -> Self {
|
||||
let inv = 1.0 / (sin_half * sin_half + cos_half * cos_half).sqrt();
|
||||
Self(inv * sin_half, inv * cos_half)
|
||||
}
|
||||
/// `MatrixUtil.PI_4`: `fromPositiveAngle(π / 4)`.
|
||||
fn quarter_pi() -> Self {
|
||||
let half = std::f32::consts::FRAC_PI_4 / 2.0;
|
||||
let sin = joml_sin(half);
|
||||
Self(sin, cos_from_sin(sin, half))
|
||||
}
|
||||
fn inverse(self) -> Self {
|
||||
Self(-self.0, self.1)
|
||||
}
|
||||
fn cos(self) -> f32 {
|
||||
self.1 * self.1 - self.0 * self.0
|
||||
}
|
||||
fn sin(self) -> f32 {
|
||||
2.0 * self.0 * self.1
|
||||
}
|
||||
/// `aroundX/Y/Z(Quaternionf)` (axis 0, 1, 2).
|
||||
fn quaternion(self, axis: usize) -> [f32; 4] {
|
||||
let mut q = [0.0, 0.0, 0.0, self.1];
|
||||
q[axis] = self.0;
|
||||
q
|
||||
}
|
||||
/// `aroundX/Y/Z(Matrix3f)` (axis 0, 1, 2).
|
||||
fn matrix(self, axis: usize) -> Mat3 {
|
||||
let (cos, sin) = (self.cos(), self.sin());
|
||||
let (a, b) = ((axis + 1) % 3, (axis + 2) % 3);
|
||||
let mut m = [[0.0; 3]; 3];
|
||||
m[axis][axis] = 1.0;
|
||||
m[a][a] = cos;
|
||||
m[b][b] = cos;
|
||||
m[a][b] = sin;
|
||||
m[b][a] = -sin;
|
||||
m
|
||||
}
|
||||
}
|
||||
|
||||
/// `MatrixUtil.approxGivensQuat`.
|
||||
fn approx_givens(a11: f32, a12: f32, a22: f32) -> Givens {
|
||||
let g = 3.0 + 2.0 * 2.0f32.sqrt();
|
||||
let (cos_half, sin_half) = (2.0 * (a11 - a22), a12);
|
||||
if g * sin_half * sin_half < cos_half * cos_half {
|
||||
Givens::unnormalized(sin_half, cos_half)
|
||||
} else {
|
||||
Givens::quarter_pi()
|
||||
}
|
||||
}
|
||||
/// `MatrixUtil.qrGivensQuat`.
|
||||
fn qr_givens(a1: f32, a2: f32) -> Givens {
|
||||
let p = f64::from(a1).hypot(f64::from(a2)) as f32;
|
||||
let mut sin_half = if p > 1.0e-6 { a2 } else { 0.0 };
|
||||
let mut cos_half = a1.abs() + if p > 1.0e-6 { p } else { 1.0e-6 };
|
||||
if a1 < 0.0 {
|
||||
std::mem::swap(&mut sin_half, &mut cos_half);
|
||||
}
|
||||
Givens::unnormalized(sin_half, cos_half)
|
||||
}
|
||||
/// `MatrixUtil.stepJacobi`: the xy, xz then yz pairs, each by
|
||||
/// `similarityTransform` (`R^T * m * R`).
|
||||
fn step_jacobi(m: &mut Mat3, q: &mut [f32; 4]) {
|
||||
let mut rotate = |m: &mut Mat3, g: Givens, axis: usize| {
|
||||
*q = quaternion_mul(*q, g.quaternion(axis));
|
||||
let r = g.matrix(axis);
|
||||
*m = mul3(&transpose3(&r), &mul3(m, &r));
|
||||
};
|
||||
if m[0][1] * m[0][1] + m[1][0] * m[1][0] > 1.0e-6 {
|
||||
let g = approx_givens(m[0][0], 0.5 * (m[0][1] + m[1][0]), m[1][1]);
|
||||
rotate(m, g, 2);
|
||||
}
|
||||
if m[0][2] * m[0][2] + m[2][0] * m[2][0] > 1.0e-6 {
|
||||
let g = approx_givens(m[0][0], 0.5 * (m[0][2] + m[2][0]), m[2][2]).inverse();
|
||||
rotate(m, g, 1);
|
||||
}
|
||||
if m[1][2] * m[1][2] + m[2][1] * m[2][1] > 1.0e-6 {
|
||||
let g = approx_givens(m[1][1], 0.5 * (m[1][2] + m[2][1]), m[2][2]);
|
||||
rotate(m, g, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `MatrixUtil.svdDecompose(Matrix4fc, ...)`, how `new Transformation(matrix)`
|
||||
/// splits a matrix: translation, left rotation, scale, right rotation. The
|
||||
/// right rotation diagonalizes `a^T * a` (`eigenvalueJacobi`, 5 steps), then
|
||||
/// QR Givens rotations about z, y and x bring `a * R(right)` to the scale.
|
||||
pub fn svd_decompose(m: &Mat4) -> ([f32; 3], [f32; 4], [f32; 3], [f32; 4]) {
|
||||
let inv = 1.0 / m.0[3][3];
|
||||
let a: Mat3 = std::array::from_fn(|c| std::array::from_fn(|r| m.0[c][r] * inv));
|
||||
let mut b = mul3(&transpose3(&a), &a);
|
||||
let mut right = [0.0, 0.0, 0.0, 1.0];
|
||||
for _ in 0..5 {
|
||||
step_jacobi(&mut b, &mut right);
|
||||
}
|
||||
right = normalize_quaternion(right);
|
||||
let (x_small, y_small) = (f64::from(b[0][0]) < 1.0e-6, f64::from(b[1][1]) < 1.0e-6);
|
||||
let mut left = [0.0, 0.0, 0.0, 1.0];
|
||||
let mut r = rotate3(&a, right);
|
||||
let mut qr = |r: &mut Mat3, g: Givens, axis: usize| {
|
||||
left = quaternion_mul(left, g.quaternion(axis));
|
||||
*r = mul3(&transpose3(&g.matrix(axis)), r);
|
||||
};
|
||||
let g = if x_small {
|
||||
qr_givens(r[1][1], -r[1][0])
|
||||
} else {
|
||||
qr_givens(r[0][0], r[0][1])
|
||||
};
|
||||
qr(&mut r, g, 2);
|
||||
let g = if x_small {
|
||||
qr_givens(r[2][2], -r[2][0])
|
||||
} else {
|
||||
qr_givens(r[0][0], r[0][2])
|
||||
};
|
||||
qr(&mut r, g.inverse(), 1);
|
||||
let g = if y_small {
|
||||
qr_givens(r[2][2], -r[2][1])
|
||||
} else {
|
||||
qr_givens(r[1][1], r[1][2])
|
||||
};
|
||||
qr(&mut r, g, 0);
|
||||
let scale = [r[0][0], r[1][1], r[2][2]];
|
||||
let translation = std::array::from_fn(|i| m.0[3][i] * inv);
|
||||
let [x, y, z, w] = right;
|
||||
(translation, left, scale, [-x, -y, -z, w])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -355,6 +526,51 @@ mod tests {
|
||||
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn svd_decomposes_like_matrix_util() {
|
||||
let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::<Vec<_>>();
|
||||
let parts = |m: [f32; 16]| {
|
||||
let (t, l, s, r) = svd_decompose(&Mat4::from_row_major(m));
|
||||
[bits(&t), bits(&l), bits(&s), bits(&r)]
|
||||
};
|
||||
// Values from MatrixUtil.svdDecompose on the 26.3 jar.
|
||||
let scaled = [
|
||||
2.0, 0.0, 0.0, 1.0, 0.0, 3.0, 0.0, -2.0, 0.0, 0.0, 0.5, 0.25, 0.0, 0.0, 0.0, 1.0,
|
||||
];
|
||||
assert_eq!(
|
||||
parts(scaled),
|
||||
[
|
||||
vec![0x3f80_0000u32, 0xc000_0000, 0x3e80_0000],
|
||||
vec![0u32, 0, 0, 0x3f80_0000],
|
||||
vec![0x4000_0000u32, 0x4040_0000, 0x3f00_0000],
|
||||
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
|
||||
]
|
||||
);
|
||||
let quarter_turn = [
|
||||
0.0, -1.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||
];
|
||||
assert_eq!(
|
||||
parts(quarter_turn),
|
||||
[
|
||||
vec![0x3f00_0000u32, 0, 0],
|
||||
vec![0u32, 0, 0x3f35_04f3, 0x3f35_04f3],
|
||||
vec![0x3f7f_ffffu32, 0x3f7f_ffff, 0x3f80_0000],
|
||||
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
|
||||
]
|
||||
);
|
||||
let sheared = [
|
||||
1.0, 0.5, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.25, 0.0, 2.0, 3.0, 0.0, 0.0, 0.0, 2.0,
|
||||
];
|
||||
assert_eq!(
|
||||
parts(sheared),
|
||||
[
|
||||
vec![0u32, 0, 0x3fc0_0000],
|
||||
vec![0x3c03_7010u32, 0x3d38_d722, 0x3ea8_ef95, 0x3f71_60d9],
|
||||
vec![0x3f23_2630u32, 0x3ec6_bdde, 0x3f81_5b1f],
|
||||
vec![0xbcc5_7e11u32, 0xbda8_1e36, 0xbee2_916d, 0x3f64_860a],
|
||||
]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn half_turn_has_exact_zeros_like_joml() {
|
||||
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
|
||||
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
|
||||
|
||||
@@ -63,9 +63,12 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
@@ -142,6 +145,7 @@ struct Options {
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
no_loaded: bool,
|
||||
@@ -174,6 +178,7 @@ fn options() -> Result<Options, String> {
|
||||
explore_speed: 22.0,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
no_loaded: false,
|
||||
@@ -197,6 +202,10 @@ fn options() -> Result<Options, String> {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-swing" {
|
||||
o.no_swing = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-loaded" {
|
||||
o.no_loaded = true;
|
||||
continue;
|
||||
@@ -335,8 +344,8 @@ struct Bot {
|
||||
counters: OnceLock<Arc<Counters>>,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<(Option<u128>, Option<i32>)>,
|
||||
/// `--attack`: each target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<Vec<(u128, Option<i32>)>>,
|
||||
attacks: AtomicU64,
|
||||
/// The last SetHealth.
|
||||
health: Mutex<Option<f32>>,
|
||||
@@ -701,9 +710,10 @@ fn read(
|
||||
}
|
||||
cb::ADD_ENTITY => {
|
||||
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
|
||||
let mut target = lock(&bot.target);
|
||||
if target.0 == Some(p.uuid) {
|
||||
target.1 = Some(p.id);
|
||||
for target in lock(&bot.target).iter_mut() {
|
||||
if target.0 == p.uuid {
|
||||
target.1 = Some(p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -802,7 +812,12 @@ fn run_bot(
|
||||
let (reader, writer) = connection.split();
|
||||
let _ = bot.counters.set(reader.counters.clone());
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
|
||||
*lock(&bot.target) = o
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
@@ -1012,13 +1027,16 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let target = lock(&bot.target).1;
|
||||
if let Some(id) = target
|
||||
&& joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
{
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
if !o.no_swing {
|
||||
send(play::Serverbound::Punch(Empty).encode());
|
||||
}
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
tick += 1;
|
||||
let ok = send(
|
||||
|
||||
@@ -57,6 +57,9 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
"assets/horizon/lang/zh_cn.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/zh_cn.json")
|
||||
}
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
@@ -288,6 +291,16 @@ mod tests {
|
||||
}
|
||||
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
|
||||
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
|
||||
let zh = Language::load(&[&empty], "zh_cn");
|
||||
assert_eq!(zh.get("horizon.options.controller"), Some("手柄"));
|
||||
assert_eq!(zh.get("horizon.pages.mods"), Some("模组"));
|
||||
assert_eq!(zh.get("Preset"), Some("预设"));
|
||||
assert_eq!(zh.get("%s chunks"), Some("%s 个区块"));
|
||||
// Not every bundle is complete: the en_us layer is in every map, so a
|
||||
// missing key falls back to English (`horizon.mods.not_built` is not
|
||||
// in ru_ru either).
|
||||
assert_eq!(zh.get("LOD Distance"), Some("LOD 距离"));
|
||||
assert_eq!(ru.get("horizon.mods.not_built"), Some(" (Not built)"));
|
||||
let pl = Language::load(&[&empty], "pl_pl");
|
||||
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
|
||||
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
|
||||
|
||||
@@ -272,10 +272,15 @@ pub fn potion_color(id: Option<&str>) -> u32 {
|
||||
let Some(def) = id.and_then(potion) else {
|
||||
return BASE_POTION_COLOR;
|
||||
};
|
||||
effects_color(def.effects.iter().map(|(id, _, amp)| (*id, *amp)))
|
||||
}
|
||||
/// `PotionContents.getColorOptional` of some effects, `(id, amplifier)`, all visible: the mix of
|
||||
/// their colours weighted by amplifier + 1, else the base potion colour.
|
||||
pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
|
||||
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
|
||||
for (e, _, amp) in def.effects {
|
||||
for (e, amp) in effects {
|
||||
let Some(e) = effect(e) else { continue };
|
||||
let w = *amp as u32 + 1;
|
||||
let w = amp as u32 + 1;
|
||||
r += w * (e.color >> 16 & 255);
|
||||
g += w * (e.color >> 8 & 255);
|
||||
b += w * (e.color & 255);
|
||||
@@ -301,6 +306,21 @@ mod tests {
|
||||
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
|
||||
}
|
||||
|
||||
/// `PotionContents.getColorOptional`: each visible effect's colour weighted by its
|
||||
/// amplifier + 1, the channels averaged by integer division; none gives the base colour.
|
||||
#[test]
|
||||
fn effects_mix_their_colours_by_amplifier() {
|
||||
let c = |id: &str| effect(id).unwrap().color;
|
||||
assert_eq!(effects_color([("poison", 0)].into_iter()), c("poison"));
|
||||
let (p, s) = (c("poison"), c("speed"));
|
||||
let mix = |shift: u32| (((p >> shift & 255) + 3 * (s >> shift & 255)) / 4) << shift;
|
||||
assert_eq!(
|
||||
effects_color([("poison", 0), ("speed", 2)].into_iter()),
|
||||
mix(16) | mix(8) | mix(0)
|
||||
);
|
||||
assert_eq!(effects_color(std::iter::empty()), BASE_POTION_COLOR);
|
||||
}
|
||||
|
||||
/// The only test that registers: the registry is set once per process.
|
||||
#[test]
|
||||
fn mod_effects_follow_vanilla_once_registered() {
|
||||
|
||||
@@ -192,9 +192,8 @@ impl Assets {
|
||||
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load(root, self)
|
||||
}
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the item models
|
||||
/// and the sprites' layers, without the block models or the textures'
|
||||
/// pixels.
|
||||
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
|
||||
/// layers, without the block or item models or the textures' pixels.
|
||||
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
|
||||
RenderAssets::load_with(root, self, false)
|
||||
}
|
||||
@@ -209,8 +208,13 @@ impl RenderAssets {
|
||||
for name in MATERIALS {
|
||||
sprites.insert(root, &format!("minecraft:block/{name}"))?;
|
||||
}
|
||||
let parsed = crate::model_library::ParsedModels::load(root);
|
||||
// A server draws no block: its models (about 24 MB baked) stay empty.
|
||||
// A server draws no block and no item: its block models (about 24 MB
|
||||
// baked) and item model library (7 MB) stay empty.
|
||||
let parsed = if draws {
|
||||
crate::model_library::ParsedModels::load(root)
|
||||
} else {
|
||||
crate::model_library::ParsedModels::default()
|
||||
};
|
||||
let models = if draws {
|
||||
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
|
||||
} else {
|
||||
|
||||
@@ -39,6 +39,12 @@ struct Channel {
|
||||
looping: bool,
|
||||
at: [f32; 3],
|
||||
relative: bool,
|
||||
/// Whose sounds these are: 0 the game's, another splitscreen player's (`Audio::share`).
|
||||
owner: u8,
|
||||
/// Its world (`Mixer::world_id`; 0 none).
|
||||
world: u32,
|
||||
/// The sound file it started and when, to tell another player's copy of it.
|
||||
started: Option<(String, std::time::Instant)>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
@@ -46,6 +52,15 @@ struct Mixer {
|
||||
listener: [f64; 3],
|
||||
listener_yaw: f32,
|
||||
listener_pitch: f32,
|
||||
/// Splitscreen: the other players' cameras (position, yaw, pitch) by owner. A world sound is
|
||||
/// heard from the nearest camera, and panned half as wide while several share the speakers.
|
||||
listeners: HashMap<u8, ([f64; 3], f32, f32)>,
|
||||
/// Each camera's world by owner, as ids: the callback compares and copies integers.
|
||||
worlds: HashMap<u8, u32>,
|
||||
/// World names by id - 1 (`world_id`).
|
||||
world_names: Vec<String>,
|
||||
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
|
||||
cameras: Vec<([f64; 3], Basis, u32)>,
|
||||
limiter_peak: f32,
|
||||
/// The output device the stream was opened on (`Library.currentDeviceName`).
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -54,6 +69,21 @@ struct Mixer {
|
||||
#[cfg(feature = "audio")]
|
||||
device_lost: bool,
|
||||
}
|
||||
/// A camera's forward, up and right vectors.
|
||||
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
|
||||
impl Mixer {
|
||||
/// The id of world `name` (1 and up), given once per name.
|
||||
fn world_id(&mut self, name: &str) -> u32 {
|
||||
let i = match self.world_names.iter().position(|w| w == name) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.world_names.push(name.to_owned());
|
||||
self.world_names.len() - 1
|
||||
}
|
||||
};
|
||||
i as u32 + 1
|
||||
}
|
||||
}
|
||||
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
|
||||
/// what the subtitle overlay needs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -75,7 +105,34 @@ pub struct MixerBackend {
|
||||
decoder: Option<std::sync::mpsc::Sender<Decode>>,
|
||||
next: ChannelId,
|
||||
live: bool,
|
||||
/// Whose channels these are (`Channel::owner`).
|
||||
owner: u8,
|
||||
world: u32,
|
||||
/// How many other players share this output: alone, a sound never looks for its copy.
|
||||
shares: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
/// A splitscreen player's backend leaving: its camera and sounds go with it.
|
||||
impl Drop for MixerBackend {
|
||||
fn drop(&mut self) {
|
||||
if self.owner == 0 {
|
||||
return;
|
||||
}
|
||||
self.shares
|
||||
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if let Ok(mut m) = self.mixer.lock() {
|
||||
m.listeners.remove(&self.owner);
|
||||
m.worlds.remove(&self.owner);
|
||||
let owner = self.owner;
|
||||
m.channels.retain(|_, c| c.owner != owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Splitscreen: each shared backend's channel ids start at a block of their own, never handed out
|
||||
/// again, so a player in a seat used before never reuses an id a departed one's decoder may
|
||||
/// still attach to. The game's own backend counts from 0, as before.
|
||||
static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
/// Two players' copies of one sound: started within this of each other.
|
||||
const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150);
|
||||
/// Decoded samples and their rate (`None`: the file did not load).
|
||||
type Samples = Option<(Arc<Vec<f32>>, u32)>;
|
||||
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
|
||||
@@ -176,6 +233,11 @@ pub struct Audio {
|
||||
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: Option<String>,
|
||||
/// The ALSA cards (Linux) or Core Audio devices (macOS) last seen: listing
|
||||
/// the output devices probes every one, so it waits for these to change
|
||||
/// (`card_fingerprint`).
|
||||
#[cfg(feature = "audio")]
|
||||
cards_seen: Option<String>,
|
||||
/// The other half of `lastSeenDevices`: every output device last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: Vec<String>,
|
||||
@@ -225,7 +287,59 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
|
||||
}
|
||||
Some((mono, rate))
|
||||
}
|
||||
/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each
|
||||
/// player's game picks its own variant of the same event.
|
||||
// ponytail: variants named by a trailing number, as vanilla's are; the event name if a pack breaks it.
|
||||
fn variantless(key: &str) -> &str {
|
||||
key.trim_end_matches(".ogg")
|
||||
.trim_end_matches(|c: char| c.is_ascii_digit())
|
||||
}
|
||||
impl MixerBackend {
|
||||
/// Splitscreen: another player's game started this same sound here just now (both clients
|
||||
/// heard it); this copy stays stopped, the first is heard from the nearest camera.
|
||||
///
|
||||
/// A copy is recognised only by its file (without the variant), world, place and time: the
|
||||
/// games pick variants on their own and play many sounds client-side (steps, digging), so
|
||||
/// nothing marks two copies of one event. Two different events alike in all four (two
|
||||
/// players' steps on grass, side by side, within 150 ms) are heard once. That is accepted:
|
||||
/// at that distance and timing they sound like one; a doubled sound is the louder fault.
|
||||
fn duplicate(&self, channel: ChannelId, (namespace, path): (&str, &str)) -> bool {
|
||||
// Alone: nobody else's copy, and no lock taken for it.
|
||||
if self.shares.load(std::sync::atomic::Ordering::Relaxed) == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut m) = self.mixer.lock() else {
|
||||
return false;
|
||||
};
|
||||
// Alone (no other player's camera), or a sound by the listener (a click, music): its own.
|
||||
let Some(c) = m.channels.get(&channel).filter(|c| !c.relative) else {
|
||||
return false;
|
||||
};
|
||||
if m.listeners.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let key = format!("{namespace}:{path}");
|
||||
let key = variantless(&key);
|
||||
let at = c.at;
|
||||
let world = c.world;
|
||||
let twin = m.channels.values().any(|o| {
|
||||
o.owner != self.owner
|
||||
&& o.world == world
|
||||
&& !o.relative
|
||||
&& o.started
|
||||
.as_ref()
|
||||
.is_some_and(|(k, t)| k == key && t.elapsed() < SAME_SOUND)
|
||||
&& (0..3).map(|i| (o.at[i] - at[i]).powi(2)).sum::<f32>() < 4.0
|
||||
});
|
||||
if let Some(c) = m.channels.get_mut(&channel) {
|
||||
if twin {
|
||||
c.state = State::Stopped;
|
||||
} else {
|
||||
c.started = Some((key.to_owned(), std::time::Instant::now()));
|
||||
}
|
||||
}
|
||||
twin
|
||||
}
|
||||
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
@@ -250,6 +364,9 @@ impl Backend for MixerBackend {
|
||||
looping: false,
|
||||
at: [0.0; 3],
|
||||
relative: false,
|
||||
owner: self.owner,
|
||||
world: self.world,
|
||||
started: None,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
@@ -287,6 +404,10 @@ impl Backend for MixerBackend {
|
||||
}
|
||||
let (namespace, path) = sound.path();
|
||||
let file = self.root.join("assets").join(namespace).join(&path);
|
||||
// A looping sound follows its entity in one client's tracking only: each keeps its own.
|
||||
if !looping && self.duplicate(channel, (namespace, &path)) {
|
||||
return;
|
||||
}
|
||||
if sound.stream {
|
||||
// A streamed sound loops by restarting its stream.
|
||||
let mixer = self.mixer.clone();
|
||||
@@ -395,28 +516,50 @@ fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
|
||||
#[cfg_attr(not(feature = "audio"), allow(dead_code))]
|
||||
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
data.fill(0.0);
|
||||
let listener = mixer.listener;
|
||||
// The camera's forward and up vectors (`Camera.forwardVector`/`upVector`)
|
||||
// and OpenAL's right = forward x up.
|
||||
let (yaw, pitch) = (
|
||||
f64::from(mixer.listener_yaw.to_radians()),
|
||||
f64::from(mixer.listener_pitch.to_radians()),
|
||||
let basis = |yaw: f32, pitch: f32| {
|
||||
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let up = [
|
||||
-yaw.sin() * pitch.sin(),
|
||||
pitch.cos(),
|
||||
yaw.cos() * pitch.sin(),
|
||||
];
|
||||
let right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
forward[0] * up[1] - forward[1] * up[0],
|
||||
];
|
||||
(forward, up, right)
|
||||
};
|
||||
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
|
||||
let mut cameras = std::mem::take(&mut mixer.cameras);
|
||||
cameras.clear();
|
||||
cameras.extend(
|
||||
std::iter::once((
|
||||
0,
|
||||
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
|
||||
))
|
||||
.chain(
|
||||
mixer
|
||||
.listeners
|
||||
.iter()
|
||||
.map(|(&owner, &camera)| (owner, camera)),
|
||||
)
|
||||
.map(|(owner, (at, yaw, pitch))| {
|
||||
(
|
||||
at,
|
||||
basis(yaw, pitch),
|
||||
mixer.worlds.get(&owner).copied().unwrap_or_default(),
|
||||
)
|
||||
}),
|
||||
);
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let up = [
|
||||
-yaw.sin() * pitch.sin(),
|
||||
pitch.cos(),
|
||||
yaw.cos() * pitch.sin(),
|
||||
];
|
||||
let right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
forward[0] * up[1] - forward[1] * up[0],
|
||||
];
|
||||
let shared = cameras.len() > 1;
|
||||
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
|
||||
@@ -427,27 +570,48 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
let d = if c.relative {
|
||||
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
|
||||
} else {
|
||||
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
|
||||
let away = |listener: [f64; 3]| {
|
||||
[
|
||||
f64::from(c.at[0]) - listener[0],
|
||||
f64::from(c.at[1]) - listener[1],
|
||||
f64::from(c.at[2]) - listener[2],
|
||||
at[0] - listener[0],
|
||||
at[1] - listener[1],
|
||||
at[2] - listener[2],
|
||||
]
|
||||
};
|
||||
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
// The nearest camera hears it (one camera but in splitscreen).
|
||||
let camera = cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1));
|
||||
// Unheard sources still advance: returning to a world must not resume stale sounds.
|
||||
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
|
||||
let d = if c.relative { at } else { away(listener) };
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
let gain = if camera.is_some() {
|
||||
c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [left, right] = if c.relative {
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
.map(|pan| gain * pan);
|
||||
let [left, right] = [left, right].map(|pan| gain * pan);
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
mixer.cameras = cameras;
|
||||
for (id, [left, right, gain], step) in voices {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
@@ -538,6 +702,36 @@ fn open_device(
|
||||
}
|
||||
Ok((stream, config))
|
||||
}
|
||||
/// Try one output device for `open`: skip it when it was already tried, log it
|
||||
/// when it opens, and keep its error when it does not.
|
||||
#[cfg(feature = "audio")]
|
||||
fn try_open_device(
|
||||
device: &cpal::Device,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
tried: &mut Vec<String>,
|
||||
errors: &mut Vec<String>,
|
||||
) -> Option<cpal::Stream> {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if tried.contains(&name) {
|
||||
return None;
|
||||
}
|
||||
tried.push(name.clone());
|
||||
match open_device(device, mixer) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
Some(stream)
|
||||
}
|
||||
Err(e) => {
|
||||
errors.push(format!("{name}: {e}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The name of the system's default output device.
|
||||
#[cfg(feature = "audio")]
|
||||
fn default_output_name() -> Option<String> {
|
||||
@@ -545,6 +739,42 @@ fn default_output_name() -> Option<String> {
|
||||
.default_output_device()
|
||||
.and_then(|d| d.name().ok())
|
||||
}
|
||||
/// A cheap fingerprint of what `output_devices` can list. On Linux, the ALSA
|
||||
/// cards: the rest are `cpal`'s fixed names. On macOS, every Core Audio
|
||||
/// device's name, inputs included: telling which of them play opens each one
|
||||
/// (about 90 ms a second with twenty devices), naming them takes 2 or 3 ms.
|
||||
/// `None` elsewhere, where the list is always looked at.
|
||||
#[cfg(feature = "audio")]
|
||||
fn card_fingerprint() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::fs::read_to_string("/proc/asound/cards").ok()
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let devices = cpal::default_host().devices().ok()?;
|
||||
Some(
|
||||
devices
|
||||
.filter_map(|d| d.name().ok())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Whether the output device list should be looked at again: only the ALSA
|
||||
/// cards can add or remove an entry, and `None` means there is no fingerprint,
|
||||
/// so the list has to be checked as before.
|
||||
#[cfg(any(feature = "audio", test))]
|
||||
fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
|
||||
match (seen, cards) {
|
||||
(Some(seen), Some(cards)) => seen != cards,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
@@ -649,6 +879,17 @@ impl Audio {
|
||||
};
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
engine.loaded = live;
|
||||
#[cfg(feature = "audio")]
|
||||
let cards = if live { card_fingerprint() } else { None };
|
||||
// Where the cards gate the list (Linux, macOS), it starts empty: opening the
|
||||
// output does not need it, and listing it probes every device; the first
|
||||
// change fills it. Elsewhere it starts with every device listed, as before.
|
||||
#[cfg(feature = "audio")]
|
||||
let devices = if live && cards.is_none() {
|
||||
output_devices()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
Self {
|
||||
_stream: stream,
|
||||
device_name: target_device.to_owned(),
|
||||
@@ -660,6 +901,9 @@ impl Audio {
|
||||
decoder: None,
|
||||
next: 0,
|
||||
live,
|
||||
owner: 0,
|
||||
world: 0,
|
||||
shares: Arc::default(),
|
||||
},
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
@@ -674,7 +918,9 @@ impl Audio {
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: if live { default_output_name() } else { None },
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: if live { output_devices() } else { Vec::new() },
|
||||
cards_seen: cards,
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: devices,
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
}
|
||||
@@ -741,29 +987,20 @@ impl Audio {
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let mut errors = Vec::new();
|
||||
// The default device first, then any other output a host lists:
|
||||
// a default that cannot open (busy, or a stale ALSA default) should
|
||||
// not leave the game silent when another device works.
|
||||
let default = host.default_output_device();
|
||||
let others = host.output_devices().into_iter().flatten();
|
||||
// The default device first: it is what plays, and listing the others
|
||||
// probes every PCM, which is slow and prints ALSA's errors for the
|
||||
// plugins this system lacks.
|
||||
let mut tried = Vec::new();
|
||||
for device in default.into_iter().chain(others) {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if tried.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
tried.push(name.clone());
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(e) => errors.push(format!("{name}: {e}")),
|
||||
if let Some(device) = host.default_output_device()
|
||||
&& let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors)
|
||||
{
|
||||
return Ok(stream);
|
||||
}
|
||||
// A default that cannot open (busy, or a stale ALSA default) should not
|
||||
// leave the game silent when another device works.
|
||||
for device in host.output_devices().into_iter().flatten() {
|
||||
if let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors) {
|
||||
return Ok(stream);
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
@@ -777,15 +1014,75 @@ impl Audio {
|
||||
backend: &mut self.backend,
|
||||
}
|
||||
}
|
||||
/// World identity for this player's sources and camera. Existing channels keep their world.
|
||||
pub fn set_world(&mut self, world: &str) {
|
||||
if let Ok(mut mixer) = self.backend.mixer.lock() {
|
||||
let id = mixer.world_id(world);
|
||||
self.backend.world = id;
|
||||
mixer.worlds.insert(self.backend.owner, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// `SoundManager.updateSource`: the camera position and rotation
|
||||
/// (`Listener.setTransform` takes its forward and up vectors).
|
||||
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.listener = position;
|
||||
m.listener_yaw = yaw;
|
||||
m.listener_pitch = pitch;
|
||||
if self.backend.owner == 0 {
|
||||
m.listener = position;
|
||||
m.listener_yaw = yaw;
|
||||
m.listener_pitch = pitch;
|
||||
} else {
|
||||
m.listeners
|
||||
.insert(self.backend.owner, (position, yaw, pitch));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Splitscreen: another player's sounds on this output, heard from its camera too
|
||||
/// (`owner`: 1 to 255, its own). Dropped, its camera and sounds go.
|
||||
pub fn share(&self, owner: u8) -> Self {
|
||||
assert_ne!(owner, 0, "shared audio owners must be nonzero");
|
||||
self.backend
|
||||
.shares
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let mut engine = SoundEngine::new(self.engine.registry.clone());
|
||||
engine.loaded = self.backend.live;
|
||||
Self {
|
||||
_stream: None,
|
||||
device_name: self.device_name.clone(),
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer: self.backend.mixer.clone(),
|
||||
root: self.backend.root.clone(),
|
||||
cache: self.backend.cache.clone(),
|
||||
decoder: None,
|
||||
next: (SHARED_CHANNELS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1)
|
||||
<< 40,
|
||||
live: self.backend.live,
|
||||
owner,
|
||||
world: self.backend.world,
|
||||
shares: self.backend.shares.clone(),
|
||||
},
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
master: self.master,
|
||||
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
|
||||
source_override: None,
|
||||
capture: None,
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
// The device belongs to the first player's output, which looks after it.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: self.default_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
cards_seen: self.cards_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
devices_seen: self.devices_seen.clone(),
|
||||
#[cfg(feature = "audio")]
|
||||
device_ticks: 0,
|
||||
}
|
||||
}
|
||||
/// Sync the volume options, refreshing the categories that changed
|
||||
@@ -810,6 +1107,11 @@ impl Audio {
|
||||
/// at once a second.
|
||||
#[cfg(feature = "audio")]
|
||||
fn should_change_device(&mut self) -> bool {
|
||||
// A splitscreen share plays through the first player's output: it leaves the device,
|
||||
// and the mixer's lost flag, to that output.
|
||||
if self.backend.owner != 0 {
|
||||
return false;
|
||||
}
|
||||
let (current, lost) = match self.backend.mixer.lock() {
|
||||
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
|
||||
Err(_) => return false,
|
||||
@@ -817,6 +1119,7 @@ impl Audio {
|
||||
if lost {
|
||||
log::info!("Horizon: audio device was lost");
|
||||
self.default_seen = default_output_name();
|
||||
self.cards_seen = card_fingerprint();
|
||||
self.devices_seen = output_devices();
|
||||
return true;
|
||||
}
|
||||
@@ -826,6 +1129,17 @@ impl Audio {
|
||||
}
|
||||
self.device_ticks = 0;
|
||||
let default = default_output_name();
|
||||
let cards = card_fingerprint();
|
||||
// Listing the devices probes every one (on Linux every built-in PCM,
|
||||
// printing ALSA's errors for the plugins this system lacks; on macOS
|
||||
// every Core Audio device, about 90 ms with twenty of them); only the
|
||||
// fingerprint's devices can change the list, and it is cheap.
|
||||
if default == self.default_seen
|
||||
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.cards_seen = cards;
|
||||
let devices = output_devices();
|
||||
// Only a change in the device lists is looked at, as in vanilla: a
|
||||
// preferred device that is listed but cannot open is not retried.
|
||||
@@ -1140,6 +1454,9 @@ mod tests {
|
||||
looping,
|
||||
at,
|
||||
relative: false,
|
||||
owner: 0,
|
||||
world: 0,
|
||||
started: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1297,6 +1614,185 @@ mod tests {
|
||||
assert!(close(gains(0.0, 90.0, [0.0, 0.0, 1.0]), [0.5, 0.5]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splitscreen_sounds_are_heard_from_the_nearest_camera() {
|
||||
// P1 far away, a second player beside the source: heard at full gain, centred.
|
||||
let mut m = Mixer {
|
||||
listener: [100.0, 0.0, 0.0],
|
||||
..Mixer::default()
|
||||
};
|
||||
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
|
||||
m.channels
|
||||
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
|
||||
let mut data = [0.0; 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert!(
|
||||
data[0] > 0.5 && (data[0] - data[1]).abs() < 1e-4,
|
||||
"{data:?}"
|
||||
);
|
||||
// Alone, P1 would not hear it: 100 blocks past its 16.
|
||||
m.listeners.clear();
|
||||
let mut data = [0.0; 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(data, [0.0; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_listener_registers_even_in_the_hosts_world() {
|
||||
let mut host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
|
||||
host.set_world("minecraft:overworld");
|
||||
let mut guest = host.share(1);
|
||||
guest.set_world("minecraft:overworld");
|
||||
guest.set_listener([1.0, 2.0, 3.0], 0.0, 0.0);
|
||||
let id = guest.backend.create(false).unwrap();
|
||||
let mixer = host.backend.mixer.lock().unwrap();
|
||||
let overworld = mixer
|
||||
.world_names
|
||||
.iter()
|
||||
.position(|w| w == "minecraft:overworld");
|
||||
let overworld = overworld.map(|i| i as u32 + 1);
|
||||
assert_eq!(mixer.worlds.get(&1).copied(), overworld);
|
||||
assert_eq!(Some(mixer.channels[&id].world), overworld);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cameras_in_other_dimensions_cannot_hear_a_source() {
|
||||
let mut m = Mixer {
|
||||
listener: [100.0, 0.0, 0.0],
|
||||
..Default::default()
|
||||
};
|
||||
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
|
||||
let nether = m.world_id("nether");
|
||||
m.worlds.insert(1, nether);
|
||||
m.channels
|
||||
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
|
||||
let mut data = [0.0; 2];
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(
|
||||
data, [0.0; 2],
|
||||
"nearby Nether camera must not hear Overworld source"
|
||||
);
|
||||
m.channels.get_mut(&1).unwrap().world = nether;
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert!(data[0] > 0.5, "same-world camera must hear source");
|
||||
let end = m.world_id("end");
|
||||
m.worlds.insert(1, end);
|
||||
data.fill(0.0);
|
||||
mix(&mut m, &mut data, 2, 48_000);
|
||||
assert_eq!(data, [0.0; 2], "no listener remains in source's world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sounds_in_a_world_without_listeners_finish_while_inaudible() {
|
||||
let mut mixer = Mixer::default();
|
||||
let mut source = channel(vec![1.0; 4], None, [0.0; 3], false);
|
||||
let nether = mixer.world_id("nether");
|
||||
source.world = nether;
|
||||
mixer.channels.insert(1, source);
|
||||
let mut data = [0.0; 4];
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(data, [0.0; 4]);
|
||||
assert!(mixer.channels[&1].position > 0.0);
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(mixer.channels[&1].state, State::Stopped);
|
||||
mixer.worlds.insert(0, nether);
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_of_one_sound_are_the_same_sound() {
|
||||
assert_eq!(
|
||||
variantless("minecraft:mob/creeper/say1.ogg"),
|
||||
"minecraft:mob/creeper/say"
|
||||
);
|
||||
assert_eq!(
|
||||
variantless("minecraft:step/grass6.ogg"),
|
||||
"minecraft:step/grass"
|
||||
);
|
||||
assert_eq!(
|
||||
variantless("minecraft:random/click.ogg"),
|
||||
"minecraft:random/click"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_players_copy_of_a_sound_is_dropped() {
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
// One other player shares the output.
|
||||
let shares = Arc::new(std::sync::atomic::AtomicUsize::new(1));
|
||||
let backend = |owner| MixerBackend {
|
||||
mixer: mixer.clone(),
|
||||
root: PathBuf::new(),
|
||||
cache: Arc::default(),
|
||||
decoder: None,
|
||||
next: (u64::from(owner)) << 40,
|
||||
live: true,
|
||||
owner,
|
||||
world: 0,
|
||||
shares: shares.clone(),
|
||||
};
|
||||
let (mut p1, mut p2) = (backend(0), backend(1));
|
||||
// P2 is listening (its camera is in the mixer), so two players share the speakers.
|
||||
mixer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.listeners
|
||||
.insert(1, ([0.0; 3], 0.0, 0.0));
|
||||
let started = |b: &mut MixerBackend, at: [f64; 3]| {
|
||||
let id = b.create(false).unwrap();
|
||||
b.set_position(id, at);
|
||||
!b.duplicate(id, ("minecraft", "mob/creeper/say1.ogg"))
|
||||
};
|
||||
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
|
||||
// P2's client got the same sound at the same place: one copy plays.
|
||||
assert!(!started(&mut p2, [5.2, 64.0, 5.0]));
|
||||
// Elsewhere, or P1's own second one, it is a sound of its own.
|
||||
assert!(started(&mut p2, [40.0, 64.0, 5.0]));
|
||||
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
|
||||
// Equal coordinates in another dimension describe a different sound.
|
||||
p2.world = mixer.lock().unwrap().world_id("nether");
|
||||
assert!(started(&mut p2, [5.0, 64.0, 5.0]));
|
||||
// P2 leaving takes its sounds.
|
||||
drop(p2);
|
||||
assert!(
|
||||
mixer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.channels
|
||||
.values()
|
||||
.all(|c| c.owner == 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reusing_a_seat_does_not_reuse_its_channels_or_leave_its_camera() {
|
||||
let host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
|
||||
let mut guest = host.share(1);
|
||||
guest.set_world("overworld");
|
||||
guest.set_listener([1.0, 0.0, 0.0], 0.0, 0.0);
|
||||
let old = guest.backend.create(false).unwrap();
|
||||
drop(guest);
|
||||
{
|
||||
let mixer = host.backend.mixer.lock().unwrap();
|
||||
assert!(!mixer.channels.contains_key(&old));
|
||||
assert!(!mixer.listeners.contains_key(&1));
|
||||
assert!(!mixer.worlds.contains_key(&1));
|
||||
}
|
||||
let mut replacement = host.share(1);
|
||||
let new = replacement.backend.create(false).unwrap();
|
||||
assert_ne!(old, new);
|
||||
assert!(
|
||||
replacement
|
||||
.backend
|
||||
.mixer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.channels
|
||||
.contains_key(&new)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mono_output_plays_sources_unpanned() {
|
||||
let mut m = Mixer::default();
|
||||
@@ -1306,4 +1802,15 @@ mod tests {
|
||||
mix(&mut m, &mut data, 1, 48_000);
|
||||
assert_eq!(data, [0.5; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
|
||||
// No fingerprint (not Linux, or the cards could not be read): the list
|
||||
// has to be looked at as before.
|
||||
assert!(cards_changed(None, None));
|
||||
assert!(cards_changed(Some("0 [PCH ]"), None));
|
||||
// The same cards list the same devices; a card added or removed does not.
|
||||
assert!(!cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]")));
|
||||
assert!(cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]\n 1 [USB ]")));
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,25 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
|
||||
}
|
||||
}
|
||||
|
||||
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
|
||||
/// The signed-in account's active skin and cape from `GET /minecraft/profile`.
|
||||
/// Single-player and integrated servers often send no `textures` property, or
|
||||
/// one without the equipped cape; the client asks the profile endpoint once.
|
||||
pub fn own_textures(session: &AuthSession) -> Option<crate::skins::Textures> {
|
||||
let response = agent()
|
||||
.get(MINECRAFT_PROFILE)
|
||||
.set("Authorization", &format!("Bearer {}", session.access_token))
|
||||
.call()
|
||||
.ok()?;
|
||||
if response.status() != 200 {
|
||||
return None;
|
||||
}
|
||||
let body = response.into_string().ok()?;
|
||||
let profile: serde_json::Value = serde_json::from_str(&body).ok()?;
|
||||
Some(crate::skins::services_profile_textures(&profile))
|
||||
}
|
||||
|
||||
/// What the session service says about a player joining this server
|
||||
/// (MinecraftServicesSessionService.hasJoinedServer).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -82,6 +82,28 @@ pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_w
|
||||
display_name(first.or(second), "container.chestDouble")
|
||||
}
|
||||
|
||||
/// The menu a container vehicle's `createMenu` builds (`ContainerEntity`):
|
||||
/// `AbstractChestBoat.createMenu` and `MinecartChest.createMenu` are
|
||||
/// `ChestMenu.threeRows` (a 9x3 menu), `MinecartHopper.createMenu` is a
|
||||
/// `HopperMenu` (five slots). `None` for any other entity type.
|
||||
pub fn vehicle_menu(entity_type: &str) -> Option<(&'static str, usize)> {
|
||||
match entity_type {
|
||||
"minecraft:chest_minecart" => Some(("minecraft:generic_9x3", 27)),
|
||||
"minecraft:hopper_minecart" => Some(("minecraft:hopper", HOPPER_SLOTS)),
|
||||
n if n.ends_with("_chest_boat") || n.ends_with("_chest_raft") => {
|
||||
Some(("minecraft:generic_9x3", 27))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `MenuProvider.getDisplayName` for a container vehicle: `Entity.getDisplayName`
|
||||
/// is its custom name, else `getName`, the entity type's name key. Horizon's
|
||||
/// vehicles keep no custom name, so this is the type's name key.
|
||||
pub fn vehicle_menu_name(entity_type: &str) -> String {
|
||||
format!("entity.{}", entity_type.replacen(':', ".", 1))
|
||||
}
|
||||
|
||||
impl Campfire {
|
||||
/// `placeFood`'s slot: the first empty one, given the item's
|
||||
/// `campfire_cooking` time; it takes one item of `held`.
|
||||
|
||||
@@ -453,7 +453,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/`, `loot_table/`, `advancement/` and `tags/…/` files, by
|
||||
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` 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>>>,
|
||||
@@ -461,14 +461,14 @@ pub struct DataStore {
|
||||
pub errors: Vec<(String, String)>,
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
|
||||
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` 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/")?;
|
||||
let (ns, rest) = rest.split_once('/')?;
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "loot_table" | "advancement" => kind,
|
||||
"recipe" | "loot_table" | "advancement" | "dialog" => kind,
|
||||
"tags" => {
|
||||
let (sub, name) = rest.rsplit_once('/')?;
|
||||
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
|
||||
@@ -497,6 +497,7 @@ impl DataStore {
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"dialog",
|
||||
"function",
|
||||
"item_modifier",
|
||||
"loot_table",
|
||||
@@ -865,6 +866,10 @@ mod tests {
|
||||
"data/mine/predicate/p.json",
|
||||
"{\"condition\":\"minecraft:random_chance\",\"chance\":1}",
|
||||
);
|
||||
write(
|
||||
"data/mine/dialog/config/points.json",
|
||||
"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}",
|
||||
);
|
||||
let packs = discover(&dir);
|
||||
assert_eq!(packs.len(), 1);
|
||||
assert_eq!(packs[0].id, "file/mypack");
|
||||
@@ -877,6 +882,11 @@ mod tests {
|
||||
["mine:a", "mine:sub/b"]
|
||||
);
|
||||
assert!(store.predicates.contains_key("mine:p"));
|
||||
// Dialogs reach the synchronized registry through the content files.
|
||||
assert_eq!(
|
||||
store.files["dialog/mine:config/points"],
|
||||
[b"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}".to_vec()]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod native_material;
|
||||
@@ -1250,6 +1251,19 @@ impl Renderer {
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Bytes of the section vertex arena's buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
@@ -3550,7 +3564,13 @@ impl Renderer {
|
||||
) {
|
||||
surface.configure(device, config);
|
||||
if let Some(hdr) = hdr {
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
horizon_hdr::set_metadata(
|
||||
device,
|
||||
surface,
|
||||
hdr.paper_white,
|
||||
hdr.declared_peak(),
|
||||
hdr.black,
|
||||
);
|
||||
}
|
||||
}
|
||||
fn configure_surface(&self) {
|
||||
@@ -3563,9 +3583,29 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// HDR calibration's test picture (`crate::hdr_calibration`) while it runs, else None.
|
||||
pub fn set_hdr_calibration(&mut self, calibration: Option<HdrCalibration>) {
|
||||
let Some(hdr) = &mut self.hdr else {
|
||||
return;
|
||||
};
|
||||
if hdr.calibration == calibration {
|
||||
return;
|
||||
}
|
||||
let declared = hdr.declared_peak();
|
||||
hdr.calibration = calibration;
|
||||
if hdr.declared_peak() != declared {
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
fn send_hdr_metadata(&self) {
|
||||
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
horizon_hdr::set_metadata(
|
||||
&self.device,
|
||||
surface,
|
||||
hdr.paper_white,
|
||||
hdr.declared_peak(),
|
||||
hdr.black,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
@@ -3626,7 +3666,8 @@ impl Renderer {
|
||||
DisplayWatch::default()
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.hdr =
|
||||
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
|
||||
self.configure_surface();
|
||||
match output {
|
||||
Some(output) => log::info!(
|
||||
@@ -3747,9 +3788,13 @@ impl Renderer {
|
||||
/// stages' labels; on an error the pack that ran keeps running.
|
||||
pub fn set_native_shader_pack(
|
||||
&mut self,
|
||||
pack: Option<(horizon_shader_api::Report, Vec<(String, f64)>)>,
|
||||
pack: Option<(
|
||||
horizon_shader_api::Report,
|
||||
Vec<(String, f64)>,
|
||||
Vec<(u32, u32)>,
|
||||
)>,
|
||||
) -> std::result::Result<Vec<String>, String> {
|
||||
let Some((report, constants)) = pack else {
|
||||
let Some((report, constants, sprite_tags)) = pack else {
|
||||
self.native = None;
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
@@ -3758,6 +3803,7 @@ impl Renderer {
|
||||
flat: self.flat.as_ref().map(|f| &f.draw_layout),
|
||||
sprites: self.terrain_sprites,
|
||||
format: self.config.format.remove_srgb_suffix(),
|
||||
sprite_tags: &sprite_tags,
|
||||
};
|
||||
let (pack, labels) =
|
||||
native_pack::NativePack::new(&self.device, &terrain, report, &constants)?;
|
||||
|
||||
@@ -91,9 +91,22 @@ pub const TIERS: [Tier; 4] = [
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
/// A step of HDR calibration (`crate::hdr_calibration`): its test picture is drawn in nits
|
||||
/// above the controls instead of the frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Calibration {
|
||||
/// 1 black level, 2 peak brightness, 3 paper white.
|
||||
pub step: u32,
|
||||
/// The slider's value, in nits.
|
||||
pub nits: f32,
|
||||
/// Where the controls start, as a share of the height.
|
||||
pub band: f32,
|
||||
}
|
||||
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// Horizon's logo as a 1-bit mask, for calibration's test pictures.
|
||||
logo: wgpu::TextureView,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
|
||||
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
|
||||
@@ -120,10 +133,17 @@ pub struct Hdr {
|
||||
pub tiers: [Tier; 4],
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
/// Calibration's test picture while it runs.
|
||||
pub calibration: Option<Calibration>,
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
format: wgpu::TextureFormat,
|
||||
output: Output,
|
||||
) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
|
||||
@@ -143,6 +163,7 @@ impl Hdr {
|
||||
entries: &[
|
||||
texture(0),
|
||||
texture(1),
|
||||
texture(3),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
@@ -232,12 +253,13 @@ impl Hdr {
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
size: 96,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
layout,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
clear_alpha,
|
||||
@@ -254,6 +276,7 @@ impl Hdr {
|
||||
black: 0.0,
|
||||
tiers: TIERS,
|
||||
ui: 1.0,
|
||||
calibration: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +321,10 @@ impl Hdr {
|
||||
binding: 2,
|
||||
resource: self.params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
@@ -310,6 +337,16 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
|
||||
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
|
||||
/// patch through as bright as the display goes and the logo under it shows.
|
||||
pub fn declared_peak(&self) -> f32 {
|
||||
match self.calibration {
|
||||
Some(c) if c.step == 2 => 10_000.0,
|
||||
_ => self.peak,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
@@ -430,6 +467,10 @@ impl Hdr {
|
||||
binding: 2,
|
||||
resource: self.capture_params.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&self.logo),
|
||||
},
|
||||
],
|
||||
});
|
||||
queue.write_buffer(
|
||||
@@ -442,9 +483,10 @@ impl Hdr {
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let mut params = [0.0; 20];
|
||||
let c = self.calibration;
|
||||
let mut params = [0.0; 24];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
@@ -459,6 +501,9 @@ impl Hdr {
|
||||
params[12 + i] = t.start;
|
||||
params[16 + i] = t.full;
|
||||
}
|
||||
params[20] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[21] = c.map_or(0.0, |c| c.nits);
|
||||
params[22] = c.map_or(0.0, |c| c.band);
|
||||
params
|
||||
}
|
||||
|
||||
@@ -489,6 +534,47 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
|
||||
/// edges, are the logo; everything else is not.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if p[3] > 128 && p[..3].iter().all(|&c| c > 200) {
|
||||
255
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
device
|
||||
.create_texture_with_data(
|
||||
queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon HDR calibration logo"),
|
||||
size: wgpu::Extent3d {
|
||||
width: w,
|
||||
height: h,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&mask,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
|
||||
@@ -66,7 +66,7 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
|
||||
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
|
||||
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent)).color;
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu))).color;
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, false);
|
||||
@@ -91,6 +91,9 @@ pub struct TerrainShape<'a> {
|
||||
pub sprites: [f64; 2],
|
||||
/// The world target's colour format.
|
||||
pub format: wgpu::TextureFormat,
|
||||
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
|
||||
/// (`Manifest::sprite_tags`).
|
||||
pub sprite_tags: &'a [(u32, u32)],
|
||||
}
|
||||
|
||||
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
|
||||
@@ -110,17 +113,34 @@ fn vanilla_terrain() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`.
|
||||
fn shader_source(source: &str) -> String {
|
||||
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
|
||||
fn sprite_tags(tags: &[(u32, u32)]) -> String {
|
||||
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
|
||||
let mut tags = tags.to_vec();
|
||||
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
|
||||
for group in tags.chunk_by(|a, b| a.1 == b.1) {
|
||||
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
|
||||
out += &format!(
|
||||
" case {}: {{ return {}u; }}\n",
|
||||
layers.join(", "),
|
||||
group[0].1
|
||||
);
|
||||
}
|
||||
out + " default: { return 0u; }\n }\n}\n"
|
||||
}
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
|
||||
vanilla_terrain()
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
|
||||
vanilla_terrain(),
|
||||
sprite_tags(tags)
|
||||
)
|
||||
}
|
||||
|
||||
/// The material's naga module, its errors in the stage's file.
|
||||
fn module(stage: &CheckedStage) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source);
|
||||
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -134,7 +154,7 @@ impl NativeMaterial {
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<Self, String> {
|
||||
let module = module(stage)?;
|
||||
let module = module(stage, shape.sprite_tags)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
"horizon_terrain_SPRITE_ARRAYS",
|
||||
@@ -299,13 +319,15 @@ mod tests {
|
||||
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
|
||||
return Surface(apply_fog(sprite(lit)));\n}\n";
|
||||
let stage = stage(own);
|
||||
let module = module(&stage).unwrap();
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source))));
|
||||
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
|
||||
let entries: Vec<&str> = module
|
||||
.entry_points
|
||||
.iter()
|
||||
|
||||
@@ -38,6 +38,8 @@ pub struct NativeFrame {
|
||||
pub group: wgpu::BindGroup,
|
||||
start: std::time::Instant,
|
||||
frames: u32,
|
||||
/// `frame.wetness`, and when it was last written.
|
||||
wetness: (f32, Option<std::time::Instant>),
|
||||
}
|
||||
|
||||
/// The uniform `frame` at binding 0, read by `visibility`.
|
||||
@@ -80,13 +82,18 @@ impl NativeFrame {
|
||||
group,
|
||||
start: std::time::Instant::now(),
|
||||
frames: 0,
|
||||
wetness: (0.0, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes this frame's inputs, for a world of `size`.
|
||||
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
|
||||
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
|
||||
let inputs = frame_inputs(frame, size, seconds, self.frames);
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
self.wetness = (wetness(self.wetness.0, frame.pack.rain, dt), Some(now));
|
||||
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
|
||||
inputs.wetness = self.wetness.0;
|
||||
self.frames = self.frames.wrapping_add(1);
|
||||
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
|
||||
}
|
||||
@@ -357,6 +364,13 @@ impl NativePost {
|
||||
}
|
||||
}
|
||||
|
||||
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
|
||||
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
|
||||
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
|
||||
let half_life = if rain > wetness { 30.0 } else { 10.0 };
|
||||
rain + (wetness - rain) * 0.5f32.powf(dt / half_life)
|
||||
}
|
||||
|
||||
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
|
||||
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
@@ -416,6 +430,10 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
|
||||
},
|
||||
frame_index: index,
|
||||
hide_gui: u32::from(pack.hide_gui),
|
||||
precipitation: pack.biome_precipitation.clamp(0, 2) as u32,
|
||||
temperature: pack.temperature,
|
||||
downfall: pack.rainfall,
|
||||
wetness: pack.rain,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +442,13 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_shader_api::{MANIFEST, Pack, check};
|
||||
|
||||
#[test]
|
||||
fn wetness_rises_slower_than_it_dries() {
|
||||
assert_eq!(wetness(0.0, 1.0, 30.0), 0.5);
|
||||
assert_eq!(wetness(1.0, 0.0, 10.0), 0.5);
|
||||
assert_eq!(wetness(0.25, 0.25, 5.0), 0.25);
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
|
||||
@@ -128,6 +128,9 @@ struct Graph {
|
||||
/// (`Node.sourceDirections`, as the faces rather than the steps); none for
|
||||
/// the camera's.
|
||||
entered: Vec<u8>,
|
||||
/// Whether any section in the searched top layer was reached. Sections
|
||||
/// above a clamped box use this conservative sky-band answer.
|
||||
sky_reached: bool,
|
||||
/// Per section corner, during a full search: `SEES`, or how far the line
|
||||
/// from it towards the camera is known to stay in reached sections (the
|
||||
/// first sample that was not). Sections are only ever added during a
|
||||
@@ -156,8 +159,22 @@ impl Graph {
|
||||
)
|
||||
}
|
||||
fn contains(&self, key: SectionKey) -> bool {
|
||||
self.index(key)
|
||||
.is_some_and(|i| self.directions[i] & REACHED != 0)
|
||||
if let Some(i) = self.index(key) {
|
||||
self.directions[i] & REACHED != 0
|
||||
} else if (0..=self.sections).contains(&key.1)
|
||||
&& key.1 >= self.y0 + self.height
|
||||
&& (key.0 - self.from.0).abs() <= self.radius
|
||||
&& (key.2 - self.from.2).abs() <= self.radius
|
||||
{
|
||||
// Everything above the highest non-air section is open sky. A
|
||||
// full search can reach that layer from a neighbouring column,
|
||||
// so checking only this column's top cell can hide an upper
|
||||
// section that is visible through the open layer. Once any top
|
||||
// cell is reached, conservatively keep the whole sky band visible.
|
||||
self.sky_reached
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// The full search from the camera (vanilla's `scheduleFullUpdate`).
|
||||
fn search(
|
||||
@@ -174,13 +191,37 @@ impl Graph {
|
||||
self.min_y = columns.min_y;
|
||||
self.sections = columns.sections;
|
||||
self.y0 = -1;
|
||||
self.height = columns.sections + 2;
|
||||
if from.1 >= columns.sections {
|
||||
self.height = columns.sections + 2;
|
||||
} else {
|
||||
let mut max_non_air_y = 0;
|
||||
for dz in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
let Some(column) = columns.map.get(&(from.0 + dx, from.2 + dz)) else {
|
||||
continue;
|
||||
};
|
||||
for (y, &v) in column.iter().enumerate().rev() {
|
||||
let y = y as i32;
|
||||
if y <= max_non_air_y {
|
||||
break;
|
||||
}
|
||||
if v != ALL_VISIBLE {
|
||||
max_non_air_y = y;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let top_y = max_non_air_y.max(from.1);
|
||||
self.height = (top_y + 3).max(3).min(columns.sections + 2);
|
||||
}
|
||||
let side = (2 * radius + 1) as usize;
|
||||
let size = side * side * self.height as usize;
|
||||
self.directions.clear();
|
||||
self.directions.resize(size, 0);
|
||||
self.entered.clear();
|
||||
self.entered.resize(size, 0);
|
||||
self.sky_reached = false;
|
||||
self.rays.clear();
|
||||
self.rays
|
||||
.resize((side + 1) * (side + 1) * (self.height + 1) as usize, 0);
|
||||
@@ -194,7 +235,8 @@ impl Graph {
|
||||
let base = self
|
||||
.index((from.0 + dx, 0, from.2 + dz))
|
||||
.expect("column inside the searched box");
|
||||
vis[base..base + column.len()].copy_from_slice(column);
|
||||
let len = (column.len() as i32).min(self.height - 1) as usize;
|
||||
vis[base..base + len].copy_from_slice(&column[..len]);
|
||||
}
|
||||
}
|
||||
queue.clear();
|
||||
@@ -279,6 +321,9 @@ impl Graph {
|
||||
while let Some(index) = queue.pop_front() {
|
||||
let i = index as usize;
|
||||
let key = self.key(i);
|
||||
if key.1 == self.y0 + self.height - 1 {
|
||||
self.sky_reached = true;
|
||||
}
|
||||
let directions = self.directions[i];
|
||||
let entered = self.entered[i];
|
||||
let exits = if entered == 0 {
|
||||
@@ -1082,6 +1127,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A clamped search may conservatively show sections above its searched
|
||||
/// box, but it must never hide one that the full-height search reaches.
|
||||
#[test]
|
||||
fn vertical_clamping_only_adds_overdraw() {
|
||||
let mut compared = 0;
|
||||
let mut overdraw = 0;
|
||||
let mut clamped = 0;
|
||||
for seed in [0x9e37_79b9, 7, 0xdead_beef, 0x2545_f491] {
|
||||
for (spread, count, radius) in [(6, 900, 6), (12, 2600, 12)] {
|
||||
let mut o = Occlusion::new(0, 24);
|
||||
random_world(&mut o, seed, count, spread);
|
||||
let camera = at((0, 2, 0)) + DVec3::new(3.3, -5.7, 1.9);
|
||||
o.settle(camera, radius);
|
||||
let graph = o.graph.as_ref().expect("searched");
|
||||
clamped += usize::from(graph.height < o.columns.sections + 2);
|
||||
let full = reference(&o.columns, camera, radius, true);
|
||||
let from = o.columns.section_of(camera);
|
||||
let side = 2 * radius + 1;
|
||||
let height = o.columns.sections + 2;
|
||||
let full_contains = |key: SectionKey| {
|
||||
let (dx, dz, dy) = (key.0 - from.0, key.2 - from.2, key.1 + 1);
|
||||
if dx.abs() > radius || dz.abs() > radius || !(0..height).contains(&dy) {
|
||||
return false;
|
||||
}
|
||||
full[(((dz + radius) * side + dx + radius) * height + dy) as usize]
|
||||
};
|
||||
for x in from.0 - radius..=from.0 + radius {
|
||||
for z in from.2 - radius..=from.2 + radius {
|
||||
for y in -1..=o.columns.sections {
|
||||
let key = (x, y, z);
|
||||
let expected = full_contains(key);
|
||||
let actual = o.contains(key);
|
||||
compared += 1;
|
||||
assert!(
|
||||
!expected || actual,
|
||||
"clamped search hid {key:?} for seed {seed:#x}, spread {spread}, radius {radius}; from={from:?}, y0={}, height={}, expected={expected}, actual={actual}",
|
||||
graph.y0,
|
||||
graph.height
|
||||
);
|
||||
overdraw += usize::from(actual && !expected);
|
||||
if actual && !expected {
|
||||
assert!(
|
||||
y >= graph.y0 + graph.height,
|
||||
"overdraw must be above the clamped box: {key:?}, y0={}, height={}",
|
||||
graph.y0,
|
||||
graph.height
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(clamped > 0, "the comparison exercised a clamped search");
|
||||
assert!(compared > 50_000, "compared {compared} sections");
|
||||
assert!(overdraw > 0, "the conservative top-cell rule was exercised");
|
||||
}
|
||||
|
||||
/// A line checked again from the sample it failed at, after more
|
||||
/// sections were reached, ends where checking it from its corner does.
|
||||
#[test]
|
||||
@@ -1226,4 +1329,26 @@ mod tests {
|
||||
assert_eq!(graph.entered, local.entered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_clamping_bounds_height_and_reaches_open_sky() {
|
||||
let mut o = Occlusion::new(0, 24);
|
||||
o.set((0, 5, 0), 0); // Wall at section 5
|
||||
o.settle(at((0, 2, 0)), 4);
|
||||
let graph = o.graph.as_ref().expect("searched");
|
||||
assert_eq!(graph.height, 5 + 3);
|
||||
assert!(o.contains((0, 2, 0)), "camera section is reached");
|
||||
assert!(o.contains((1, 20, 1)), "open air column is reached");
|
||||
|
||||
// An enclosed cave cannot reach the sky
|
||||
let mut cave = Occlusion::new(0, 24);
|
||||
for (dx, dy, dz) in STEPS {
|
||||
cave.set((dx, 1 + dy, dz), 0);
|
||||
}
|
||||
cave.settle(at((0, 1, 0)), 4);
|
||||
assert!(
|
||||
!cave.contains((0, 10, 0)),
|
||||
"enclosed cave does not see the sky"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -334,7 +334,7 @@ pub fn lod_capped(info: &wgpu::AdapterInfo) -> bool {
|
||||
}
|
||||
/// A LOD distance as far terrain uses it on this adapter.
|
||||
pub fn lod_distance_capped(distance: u32, info: &wgpu::AdapterInfo) -> u32 {
|
||||
let distance = distance.min(1024);
|
||||
let distance = distance.min(crate::lod::MAX_CHUNKS);
|
||||
if lod_capped(info) {
|
||||
distance.min(INTEGRATED_LOD_DISTANCE)
|
||||
} else {
|
||||
@@ -472,7 +472,9 @@ impl Graphics {
|
||||
let number = |d: u32| value.parse().unwrap_or(d);
|
||||
match key.trim() {
|
||||
"preset" => g.preset = Preset::parse(value),
|
||||
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
|
||||
"lodDistance" => {
|
||||
g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS)
|
||||
}
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
@@ -499,7 +501,7 @@ impl Graphics {
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
n => n.clamp(80, 480),
|
||||
n => n.clamp(10, 480),
|
||||
}
|
||||
}
|
||||
"hdrPeak" => {
|
||||
@@ -717,7 +719,7 @@ mod tests {
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
@@ -774,12 +776,15 @@ mod tests {
|
||||
#[test]
|
||||
fn lod_distance_is_opt_in_and_bounded() {
|
||||
assert_eq!(Graphics::default().lod_distance, 0);
|
||||
for distance in [0, 64, 128, 256, 512, 1024] {
|
||||
for distance in [0, 64, 128, 256, 512, 1024, 65_536, crate::lod::MAX_CHUNKS] {
|
||||
let g = Graphics::parse(&format!("lodDistance:{distance}\n"));
|
||||
assert_eq!(g.lod_distance, distance);
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
}
|
||||
assert_eq!(Graphics::parse("lodDistance:10000\n").lod_distance, 1024);
|
||||
assert_eq!(
|
||||
Graphics::parse("lodDistance:99999999\n").lod_distance,
|
||||
crate::lod::MAX_CHUNKS
|
||||
);
|
||||
assert_eq!(Graphics::parse("lodDistance:-1\n").lod_distance, 0);
|
||||
assert_eq!(Graphics::parse("lodDistance:invalid\n").lod_distance, 0);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ pub enum H {
|
||||
HdrBlackLevel,
|
||||
/// Binds `Graphics::hdr_key`.
|
||||
HdrKey,
|
||||
/// Starts HDR calibration (`crate::hdr_calibration`).
|
||||
HdrCalibrate,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
@@ -136,6 +138,11 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrCalibrate,
|
||||
None,
|
||||
"Sets the three below full screen, one test picture at a time.",
|
||||
),
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
@@ -339,6 +346,28 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
}
|
||||
|
||||
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
|
||||
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
|
||||
/// up to the whole world.
|
||||
const LOD_STEPS: [u32; 18] = [
|
||||
0,
|
||||
64,
|
||||
128,
|
||||
256,
|
||||
512,
|
||||
1024,
|
||||
2048,
|
||||
4096,
|
||||
8192,
|
||||
16384,
|
||||
32768,
|
||||
65536,
|
||||
131072,
|
||||
262144,
|
||||
524288,
|
||||
1048576,
|
||||
2097152,
|
||||
crate::lod::MAX_CHUNKS,
|
||||
];
|
||||
|
||||
/// Horizon's graphics screens supplement the vanilla options on Video Settings.
|
||||
/// Upscaling is only there while TAA is, which does it.
|
||||
@@ -362,6 +391,46 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Whether the system set HDR up for the display, and whether the player kept it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Setup {
|
||||
/// The system says nothing: the player calibrates.
|
||||
Unknown,
|
||||
/// Peak Brightness and Paper White follow the system.
|
||||
Automatic,
|
||||
/// The system said, and the player changed them anyway.
|
||||
Changed,
|
||||
}
|
||||
fn setup(s: &Settings) -> Setup {
|
||||
setup_of(&s.horizon, crate::graphics::reported().is_some())
|
||||
}
|
||||
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
|
||||
if !reported {
|
||||
Setup::Unknown
|
||||
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
|
||||
Setup::Automatic
|
||||
} else {
|
||||
Setup::Changed
|
||||
}
|
||||
}
|
||||
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
|
||||
fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
match (item, setup(s)) {
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
|
||||
"Your display didn't say how bright it gets: calibrate to set it up."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
|
||||
"Puts Peak Brightness and Paper White back to your display's setup."
|
||||
}
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
fn on_off(on: bool) -> String {
|
||||
lang::get(if on { "On" } else { "Off" })
|
||||
}
|
||||
@@ -384,13 +453,17 @@ impl H {
|
||||
.unwrap_or(UPLOAD_STEPS.len() - 1);
|
||||
Some(i as f32 / (UPLOAD_STEPS.len() - 1) as f32)
|
||||
}
|
||||
H::LodDistance => {
|
||||
let i = LOD_STEPS
|
||||
.iter()
|
||||
.position(|&d| d >= g.lod_distance)
|
||||
.unwrap_or(LOD_STEPS.len() - 1);
|
||||
Some(i as f32 / (LOD_STEPS.len() - 1) as f32)
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
// Auto, then 80..480 by 10.
|
||||
H::HdrPaperWhite => Some(match g.hdr_paper_white {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 70.0) / 410.0,
|
||||
}),
|
||||
// Auto, then 10..480 by 10.
|
||||
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
|
||||
// Auto, then 200..2000 by 50.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
@@ -454,6 +527,11 @@ impl H {
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
Setup::Unknown => "Calibrate...",
|
||||
Setup::Automatic => "Calibrate Anyway...",
|
||||
Setup::Changed => "Use Automatic Setup",
|
||||
})),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
@@ -499,14 +577,12 @@ impl H {
|
||||
g.upload_budget_kib =
|
||||
UPLOAD_STEPS[(v * (UPLOAD_STEPS.len() - 1) as f32).round() as usize]
|
||||
}
|
||||
H::LodDistance => {
|
||||
g.lod_distance = LOD_STEPS[(v * (LOD_STEPS.len() - 1) as f32).round() as usize]
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::HdrPaperWhite => {
|
||||
g.hdr_paper_white = match (v * 41.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 70 + step * 10,
|
||||
}
|
||||
}
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
0 => 0,
|
||||
@@ -532,9 +608,6 @@ impl H {
|
||||
}
|
||||
let g = &mut s.horizon;
|
||||
match self {
|
||||
H::LodDistance => {
|
||||
g.lod_distance = step(&[0, 64, 128, 256, 512, 1024], g.lod_distance, back)
|
||||
}
|
||||
H::FarDetail => {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
@@ -604,7 +677,11 @@ impl Item {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(
|
||||
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
|
||||
H::HdrCalibrate
|
||||
| H::HdrBlackLevel
|
||||
| H::HdrPeak
|
||||
| H::HdrPaperWhite
|
||||
| H::HdrUiBrightness,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
_ => None,
|
||||
}
|
||||
@@ -713,6 +790,8 @@ pub enum Action {
|
||||
OpenHdr,
|
||||
/// Wait for a key or mouse button to bind to this name.
|
||||
BindKey(&'static str),
|
||||
/// Start HDR calibration.
|
||||
Calibrate,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -907,7 +986,7 @@ impl GraphicsUi {
|
||||
wd.x + wd.w + STYLE.gap,
|
||||
wd.y + 1.0,
|
||||
cost,
|
||||
text,
|
||||
description(item, s, text),
|
||||
);
|
||||
}
|
||||
Target::Button(action) => {
|
||||
@@ -1000,6 +1079,14 @@ impl GraphicsUi {
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrCalibrate)) if setup(s) == Setup::Changed => {
|
||||
s.horizon.hdr_peak = 0;
|
||||
s.horizon.hdr_paper_white = 0;
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrCalibrate)) => {
|
||||
vec![Action::ButtonClick, Action::Calibrate]
|
||||
}
|
||||
Target::Item(Item::Horizon(H::HdrKey)) => {
|
||||
vec![
|
||||
Action::ButtonClick,
|
||||
@@ -1257,9 +1344,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_setup_follows_what_the_system_reported() {
|
||||
let mut g = crate::graphics::Graphics::default();
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
assert_eq!(setup_of(&g, true), Setup::Automatic);
|
||||
g.hdr_paper_white = 80;
|
||||
assert_eq!(setup_of(&g, true), Setup::Changed);
|
||||
assert_eq!(setup_of(&g, false), Setup::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_advanced_setting_has_a_description() {
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
|
||||
for row in rows(level) {
|
||||
if let Row::Setting(s) = row {
|
||||
assert!(!s.text.is_empty(), "{:?}", s.item);
|
||||
@@ -1270,11 +1367,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lod_distance_cycles_in_both_levels_without_changing_near_distance() {
|
||||
fn lod_distance_slides_in_both_levels_without_changing_near_distance() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::LodDistance);
|
||||
let near = s.render_distance;
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: Off");
|
||||
assert_eq!(item.slider(&s), Some(0.0));
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let items: Vec<_> = rows(level)
|
||||
.into_iter()
|
||||
@@ -1289,14 +1387,29 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(items[render + 1], item);
|
||||
}
|
||||
for distance in [64, 128, 256, 512, 1024, 0] {
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
// Every stop is reachable and reads back as the same position.
|
||||
for (i, &distance) in LOD_STEPS.iter().enumerate() {
|
||||
let at = i as f32 / (LOD_STEPS.len() - 1) as f32;
|
||||
item.set_slider(&mut s, at);
|
||||
assert_eq!(s.horizon.lod_distance, distance);
|
||||
assert_eq!(item.slider(&s), Some(at));
|
||||
assert_eq!(s.render_distance, near);
|
||||
}
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
// The ends are off and the whole world, which the far terrain plans.
|
||||
item.set_slider(&mut s, 0.0);
|
||||
assert_eq!(s.horizon.lod_distance, 0);
|
||||
item.set_slider(&mut s, 1.0);
|
||||
assert_eq!(s.horizon.lod_distance, crate::lod::MAX_CHUNKS);
|
||||
assert_eq!(
|
||||
item.label(&s).plain(),
|
||||
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
|
||||
);
|
||||
item.set_slider(&mut s, 5.0 / 17.0);
|
||||
assert_eq!(s.horizon.lod_distance, 1024);
|
||||
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
|
||||
// A distance between stops (from the file) sits on the next one.
|
||||
s.horizon.lod_distance = 3000;
|
||||
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
|
||||
assert_eq!(
|
||||
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
|
||||
format!("Full Detail Distance: {near} chunks")
|
||||
|
||||
@@ -26,8 +26,14 @@ struct Params {
|
||||
share: vec4<f32>,
|
||||
start: vec4<f32>,
|
||||
full: vec4<f32>,
|
||||
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
|
||||
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
|
||||
// a share of the height.
|
||||
calibration: vec4<f32>,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@group(0) @binding(3) var logo: texture_2d<f32>;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
@@ -81,6 +87,48 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
|
||||
}
|
||||
|
||||
// Whether `p` falls on the logo drawn `size` pixels wide around `centre`.
|
||||
fn on_logo(p: vec2<f32>, centre: vec2<f32>, size: f32) -> bool {
|
||||
let uv = (p - centre) / size + 0.5;
|
||||
if any(uv < vec2(0.0)) || any(uv >= vec2(1.0)) {
|
||||
return false;
|
||||
}
|
||||
let texels = vec2<f32>(textureDimensions(logo));
|
||||
return textureLoad(logo, vec2<i32>(uv * texels), 0).r > 0.5;
|
||||
}
|
||||
|
||||
// A calibration step's test picture at `p`, in nits, the way display calibration
|
||||
// tools draw them: nothing in it is bright over a large area, so the display
|
||||
// doesn't dim itself while it is judged.
|
||||
fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
let top = size.y * params.calibration.z;
|
||||
let centre = vec2(size.x, top) / 2.0;
|
||||
let v = params.calibration.y;
|
||||
switch u32(params.calibration.x) {
|
||||
// Black level: the logo at the slider on 0. Lowered until it disappears,
|
||||
// it sits on the darkest the display shows.
|
||||
case 1u: {
|
||||
return select(0.0, v, on_logo(p, centre, top * 0.4));
|
||||
}
|
||||
// Peak: a tenth of the screen at 10 000 nits, which the display clips at
|
||||
// its brightest, the logo inside at the slider. Raised until it
|
||||
// disappears, it sits on that brightest.
|
||||
case 2u: {
|
||||
let side = sqrt(0.1 * size.x * size.y);
|
||||
if all(abs(p - centre) < vec2(side / 2.0)) {
|
||||
return select(10000.0, v, on_logo(p, centre, side * 0.7));
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
// Paper white: a white card at the slider, on a dim grey, to match a
|
||||
// sheet of paper held beside the screen.
|
||||
default: {
|
||||
let card = vec2(top * 0.9, top * 0.6);
|
||||
return select(3.0, v, all(abs(p - centre) < card / 2.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
@@ -93,6 +141,16 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
|
||||
let nits = vec3(calibration_nits(pos.xy, size));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
let f = textureLoad(frame, at, 0);
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
//! HDR calibration (Graphics: HDR, Calibrate...): full screen, one step at a time, the way
|
||||
//! display calibration tools do it. HDR output draws each step's test picture straight in
|
||||
//! nits above a band of controls (`hdr.wgsl`, `Hdr::calibration`), past everything that
|
||||
//! shapes the game's picture, so what the player sees is the display alone:
|
||||
//!
|
||||
//! - Black Level: Horizon's logo at the slider's brightness on 0 nits; lowered until it
|
||||
//! disappears, it sits on the darkest the display shows.
|
||||
//! - Peak Brightness: a patch of a tenth of the screen at 10 000 nits (the display clips
|
||||
//! it at its brightest), the logo inside at the slider; raised until it disappears, it
|
||||
//! sits on that brightest. Small, so the display doesn't dim a bright full screen; the
|
||||
//! HDR metadata declares PQ's 10 000 nits meanwhile, so a compositor that clips at the
|
||||
//! declared peak (KWin) lets the patch through.
|
||||
//! - Paper White: a white card at the slider, matched to a sheet of white paper held
|
||||
//! beside the screen in the light the player plays in: white is where the eyes have
|
||||
//! adapted to, lower in a dark room than by day (as ITU-R BT.2408's reference white).
|
||||
//!
|
||||
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
|
||||
//! or Escape leaves them as they were.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
settings::Settings,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Step {
|
||||
Black,
|
||||
Peak,
|
||||
PaperWhite,
|
||||
}
|
||||
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
|
||||
|
||||
/// What the options screen does after an input here.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
Nothing,
|
||||
Click,
|
||||
Changed,
|
||||
Save,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub struct Calibration {
|
||||
step: usize,
|
||||
/// Nits, in the order of `STEPS`.
|
||||
values: [f32; 3],
|
||||
/// Which values were Auto (peak, paper white): saved as Auto again unless the
|
||||
/// player moved them.
|
||||
auto: [Option<f32>; 2],
|
||||
dragging: bool,
|
||||
}
|
||||
|
||||
/// Height of the band of controls at the bottom, in GUI pixels.
|
||||
const BAND: f32 = 84.0;
|
||||
|
||||
impl Step {
|
||||
/// Range and step of the slider, in nits: the settings' own.
|
||||
fn range(self) -> (f32, f32, f32) {
|
||||
match self {
|
||||
Step::Black => (0.0, 1.0, 0.01),
|
||||
Step::Peak => (200.0, 2000.0, 50.0),
|
||||
Step::PaperWhite => (10.0, 480.0, 10.0),
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Black Level",
|
||||
Step::Peak => "Peak Brightness",
|
||||
Step::PaperWhite => "Paper White",
|
||||
}
|
||||
}
|
||||
fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Lower it until the logo just disappears into the black.",
|
||||
Step::Peak => "Raise it until the logo just disappears into the bright square.",
|
||||
Step::PaperWhite => {
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `hdr::Calibration::step`'s number.
|
||||
pub fn number(self) -> u32 {
|
||||
match self {
|
||||
Step::Black => 1,
|
||||
Step::Peak => 2,
|
||||
Step::PaperWhite => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
top: f32,
|
||||
slider: (f32, f32, f32),
|
||||
cancel: (f32, f32, f32),
|
||||
back: (f32, f32, f32),
|
||||
next: (f32, f32, f32),
|
||||
}
|
||||
fn layout(w: f32, h: f32) -> Layout {
|
||||
let top = (h - BAND).floor();
|
||||
let cx = (w / 2.0).floor();
|
||||
let by = top + 58.0;
|
||||
Layout {
|
||||
top,
|
||||
slider: (cx - 100.0, top + 32.0, 200.0),
|
||||
cancel: (cx - 152.0, by, 98.0),
|
||||
back: (cx - 49.0, by, 98.0),
|
||||
next: (cx + 54.0, by, 98.0),
|
||||
}
|
||||
}
|
||||
fn over(cursor: (f32, f32), (x, y, w): (f32, f32, f32)) -> bool {
|
||||
ui::hovered(cursor, x, y, w, 20.0)
|
||||
}
|
||||
|
||||
impl Calibration {
|
||||
pub fn new(s: &Settings) -> Self {
|
||||
let g = &s.horizon;
|
||||
Self {
|
||||
step: 0,
|
||||
values: [
|
||||
g.hdr_black_level as f32 / 100.0,
|
||||
g.peak_nits() as f32,
|
||||
g.paper_white_nits() as f32,
|
||||
],
|
||||
auto: [
|
||||
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
|
||||
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
|
||||
],
|
||||
dragging: false,
|
||||
}
|
||||
}
|
||||
fn current(&self) -> Step {
|
||||
STEPS[self.step]
|
||||
}
|
||||
fn set(&mut self, v: f32) {
|
||||
let (min, max, step) = self.current().range();
|
||||
self.values[self.step] = (min + ((v - min) / step).round() * step).clamp(min, max);
|
||||
}
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
Text::literal(match self.current() {
|
||||
Step::Black => format!("Black Level: {v:.2} nits"),
|
||||
step => format!("{}: {v:.0} nits", step.name()),
|
||||
})
|
||||
}
|
||||
/// The step, its nits and where the controls start (a share of the height), for
|
||||
/// HDR output's test picture.
|
||||
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
|
||||
(
|
||||
self.current(),
|
||||
self.values[self.step],
|
||||
layout(0.0, h).top / h.max(1.0),
|
||||
)
|
||||
}
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let l = layout(w, h);
|
||||
// Above the band HDR output draws the test picture over whatever is here.
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
|
||||
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
|
||||
let step = self.current();
|
||||
let title = Text::literal(format!(
|
||||
"HDR Calibration {}/{}: {}",
|
||||
self.step + 1,
|
||||
STEPS.len(),
|
||||
step.name()
|
||||
));
|
||||
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
|
||||
let hint = Text::literal(step.hint());
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
&hint,
|
||||
0.0,
|
||||
l.top + 18.0,
|
||||
w,
|
||||
rgba(160, 160, 160, 255),
|
||||
);
|
||||
let (min, max, _) = step.range();
|
||||
let (x, y, sw) = l.slider;
|
||||
ui::slider(
|
||||
batch,
|
||||
atlas,
|
||||
x,
|
||||
y,
|
||||
sw,
|
||||
&self.label(),
|
||||
(self.values[self.step] - min) / (max - min),
|
||||
over(cursor, l.slider) || self.dragging,
|
||||
true,
|
||||
self.dragging,
|
||||
);
|
||||
let last = self.step + 1 == STEPS.len();
|
||||
for (rect, label, active) in [
|
||||
(l.cancel, "Cancel", true),
|
||||
(l.back, "Back", self.step > 0),
|
||||
(l.next, if last { "Save" } else { "Next" }, true),
|
||||
] {
|
||||
let hover = over(cursor, rect) && active;
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
rect.0,
|
||||
rect.1,
|
||||
rect.2,
|
||||
20.0,
|
||||
&Text::literal(label),
|
||||
active,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// A press, or a drag with `drag`, at `cursor`.
|
||||
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
|
||||
let l = layout(w, h);
|
||||
let (x, _, sw) = l.slider;
|
||||
if drag {
|
||||
if !self.dragging {
|
||||
return Outcome::Nothing;
|
||||
}
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.slider) {
|
||||
self.dragging = true;
|
||||
let (min, max, _) = self.current().range();
|
||||
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
|
||||
return Outcome::Changed;
|
||||
}
|
||||
if over(cursor, l.cancel) {
|
||||
return Outcome::Cancel;
|
||||
}
|
||||
if over(cursor, l.back) && self.step > 0 {
|
||||
self.step -= 1;
|
||||
return Outcome::Click;
|
||||
}
|
||||
if over(cursor, l.next) {
|
||||
return self.next();
|
||||
}
|
||||
Outcome::Nothing
|
||||
}
|
||||
fn next(&mut self) -> Outcome {
|
||||
if self.step + 1 == STEPS.len() {
|
||||
return Outcome::Save;
|
||||
}
|
||||
self.step += 1;
|
||||
Outcome::Click
|
||||
}
|
||||
/// The slider let go: true if it was held (it clicks on release, as sliders do).
|
||||
pub fn release(&mut self) -> bool {
|
||||
std::mem::take(&mut self.dragging)
|
||||
}
|
||||
/// Left and Right step the slider, Enter goes on, Escape cancels.
|
||||
pub fn key(&mut self, key: KeyCode) -> Outcome {
|
||||
let (_, _, step) = self.current().range();
|
||||
match key {
|
||||
KeyCode::ArrowLeft | KeyCode::ArrowDown => {
|
||||
self.set(self.values[self.step] - step);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::ArrowRight | KeyCode::ArrowUp => {
|
||||
self.set(self.values[self.step] + step);
|
||||
Outcome::Changed
|
||||
}
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => self.next(),
|
||||
KeyCode::Escape => Outcome::Cancel,
|
||||
_ => Outcome::Nothing,
|
||||
}
|
||||
}
|
||||
/// Writes what the player landed on into Horizon's settings.
|
||||
pub fn save(&self, s: &mut Settings) {
|
||||
let g = &mut s.horizon;
|
||||
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
|
||||
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
|
||||
Some(auto) if auto == self.values[i] => 0,
|
||||
_ => self.values[i].round() as u32,
|
||||
});
|
||||
g.hdr_peak = peak;
|
||||
g.hdr_paper_white = paper_white;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn calibration_saves_only_on_save() {
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr_black_level = 5;
|
||||
let before = s.horizon.clone();
|
||||
let mut c = Calibration::new(&s);
|
||||
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
|
||||
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
|
||||
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
|
||||
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
|
||||
assert_eq!(s.horizon, before);
|
||||
// Black, then Peak (one step up), then Paper White, then Save.
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
|
||||
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
|
||||
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
|
||||
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
|
||||
c.save(&mut s);
|
||||
assert_eq!(s.horizon.hdr_black_level, 3);
|
||||
assert_eq!(s.horizon.hdr_peak, before.peak_nits() + 50);
|
||||
assert_eq!(s.horizon.hdr_paper_white, before.hdr_paper_white);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use std::{
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
mod pack_text;
|
||||
|
||||
/// A sprite's atlas region, and its size in GUI pixels: the size of the
|
||||
/// default resources' texture of that name, whatever the resolution of the
|
||||
/// pack that replaced it (vanilla's GUI code draws in those units: `blit`
|
||||
@@ -78,6 +80,9 @@ pub struct HudAtlas {
|
||||
pub glint: (Vec<u8>, u32, u32),
|
||||
/// `misc/enchanted_glint_armor`, the armour layers' `GlintSampler`.
|
||||
pub glint_armor: (Vec<u8>, u32, u32),
|
||||
/// A resource pack's own `core/text` shader for the GUI's text, translated;
|
||||
/// none draws the text natively.
|
||||
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
white: Rect,
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
@@ -330,7 +335,7 @@ impl HudAtlas {
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
Self::pack(
|
||||
let mut atlas = Self::pack(
|
||||
root,
|
||||
defaults,
|
||||
&[
|
||||
@@ -347,7 +352,9 @@ impl HudAtlas {
|
||||
"map",
|
||||
],
|
||||
true,
|
||||
)
|
||||
)?;
|
||||
atlas.text_shader = pack_text::load(root, defaults);
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
|
||||
@@ -669,6 +676,7 @@ impl HudAtlas {
|
||||
sprite_animations,
|
||||
glint,
|
||||
glint_armor,
|
||||
text_shader: None,
|
||||
font,
|
||||
white,
|
||||
})
|
||||
@@ -694,6 +702,9 @@ pub struct HudVertex {
|
||||
/// The `GLINT_TEXTURING` UV into the repeating glint texture, unused
|
||||
/// unless `uv.x >= 2`.
|
||||
pub glint_uv: [f32; 2],
|
||||
/// 1 for the GUI's text (`RenderPipelines.GUI_TEXT`), which a resource
|
||||
/// pack's text shader draws when it has one.
|
||||
pub text: u32,
|
||||
}
|
||||
/// Sutherland-Hodgman against one half plane, interpolating every attribute.
|
||||
fn clip_polygon(poly: &[HudVertex], axis: usize, limit: f32, keep_below: bool) -> Vec<HudVertex> {
|
||||
@@ -750,6 +761,9 @@ pub struct HudBatch {
|
||||
pub end_portal: EndPortalFill,
|
||||
/// `options.glintStrength` for the glint pass (`GlintAlpha`).
|
||||
pub glint_alpha: f32,
|
||||
/// A resource pack text shader's `GameTime`: the day's fraction
|
||||
/// (`gameTime % 24000` and the partial tick, over 24000).
|
||||
pub game_time: f32,
|
||||
/// Screen's first vertex: the preceding HUD is part of its blurred background.
|
||||
pub blur_at: u32,
|
||||
/// Player object profiles encountered while painting text this frame.
|
||||
@@ -804,6 +818,7 @@ impl HudBatch {
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
game_time: 0.0,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
}
|
||||
@@ -881,6 +896,7 @@ impl HudBatch {
|
||||
color,
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -900,6 +916,7 @@ impl HudBatch {
|
||||
color,
|
||||
depth: 0.0,
|
||||
glint_uv: glint_uv[i],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -911,6 +928,7 @@ impl HudBatch {
|
||||
color,
|
||||
depth: p[i][2],
|
||||
glint_uv: [0.0, 0.0],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1051,6 +1069,10 @@ impl HudBatch {
|
||||
[uv(cx0, cy0), uv(cx1, cy0), uv(cx1, cy1), uv(cx0, cy1)],
|
||||
tl.color,
|
||||
);
|
||||
let end = self.vertices.len();
|
||||
for v in &mut self.vertices[end - 6..] {
|
||||
v.text = tl.text;
|
||||
}
|
||||
}
|
||||
moved.push(self.vertices.len() as u32);
|
||||
let at = |v: u32| moved[((v as usize).saturating_sub(start) / 6).min(moved.len() - 1)];
|
||||
@@ -1110,6 +1132,7 @@ impl HudBatch {
|
||||
color: c[i],
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0, 0.0],
|
||||
text: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1338,6 +1361,7 @@ impl HudBatch {
|
||||
}
|
||||
}
|
||||
};
|
||||
let glyph = self.vertices.len();
|
||||
let start = self.vertices.len();
|
||||
draw(self, 0.0);
|
||||
lift(self, start, base);
|
||||
@@ -1355,6 +1379,11 @@ impl HudBatch {
|
||||
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
|
||||
}
|
||||
lift(self, start, base + 0.01);
|
||||
if !world {
|
||||
for v in &mut self.vertices[glyph..] {
|
||||
v.text = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor - x
|
||||
@@ -1490,6 +1519,10 @@ pub struct HudRenderer {
|
||||
uniform: wgpu::Buffer,
|
||||
buffer: wgpu::Buffer,
|
||||
capacity: u64,
|
||||
format: wgpu::TextureFormat,
|
||||
atlas_view: wgpu::TextureView,
|
||||
/// The resource pack's text shader, when it has one that works.
|
||||
pack_text: Option<pack_text::PackText>,
|
||||
}
|
||||
impl HudRenderer {
|
||||
pub fn new(
|
||||
@@ -1716,8 +1749,21 @@ impl HudRenderer {
|
||||
uniform,
|
||||
buffer,
|
||||
capacity,
|
||||
format,
|
||||
atlas_view: view.clone(),
|
||||
pack_text: None,
|
||||
})
|
||||
}
|
||||
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
|
||||
pub fn set_text_shader(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
shader: Option<&horizon_shaders::CoreProgram>,
|
||||
) {
|
||||
self.pack_text = shader.and_then(|shader| {
|
||||
pack_text::PackText::new(device, self.format, &self.atlas_view, shader)
|
||||
});
|
||||
}
|
||||
pub fn animate_sprites(
|
||||
&mut self,
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
@@ -1834,6 +1880,9 @@ impl HudRenderer {
|
||||
batch.end_portal,
|
||||
)),
|
||||
);
|
||||
if let Some(pack_text) = &self.pack_text {
|
||||
pack_text.prepare(uploads, encoder, batch, size);
|
||||
}
|
||||
}
|
||||
/// Draw the batch `prepare` staged.
|
||||
pub fn draw(
|
||||
@@ -1915,15 +1964,31 @@ impl HudRenderer {
|
||||
pass.set_scissor_rect(x, y, w, h);
|
||||
}
|
||||
for (range, blend) in &runs[at..end] {
|
||||
pass.set_pipeline(match blend {
|
||||
let pipeline = match blend {
|
||||
Blend::Alpha => &self.pipeline,
|
||||
Blend::Invert => &self.invert_pipeline,
|
||||
Blend::Additive => &self.additive_pipeline,
|
||||
Blend::Depth(_) => &self.depth_pipeline,
|
||||
Blend::Vignette => &self.vignette_pipeline,
|
||||
Blend::EndPortal => &self.end_portal_pipeline,
|
||||
});
|
||||
pass.draw(range.clone(), 0..1);
|
||||
};
|
||||
match &self.pack_text {
|
||||
Some(pack_text) if *blend == Blend::Alpha => {
|
||||
for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) {
|
||||
if text {
|
||||
pack_text.draw(&mut pass, part);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
} else {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.draw(part, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.draw(range.clone(), 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
@@ -2649,6 +2714,7 @@ mod tests {
|
||||
blends: Vec::new(),
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
game_time: 0.0,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! A resource pack's `core/text` shader on the GUI's text (`RenderPipelines.GUI_TEXT`, the shader
|
||||
//! with `IS_GUI`): servers' HUDs that their pack's vertex shader moves to the screen's corners,
|
||||
//! and text effects in its fragment shader. Without one the text draws natively.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use horizon_shaders::{CoreProgram, Glue};
|
||||
|
||||
use super::{HudBatch, HudVertex};
|
||||
|
||||
/// GUI text's vertex format (`POSITION_TEX_COLOR`) from the HUD's vertices. A glyph is two
|
||||
/// triangles here, its corners top left, top right, bottom right, top left, bottom right, bottom
|
||||
/// left; vanilla's quads go top left, bottom left, bottom right, top right, which is what
|
||||
/// `gl_VertexID % 4` tells a pack's shader. The draw passes its first vertex as its instance.
|
||||
const GLUE: Glue = Glue {
|
||||
inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")],
|
||||
prelude: "layout(location = 0) in vec2 hz_pos;\n\
|
||||
layout(location = 1) in vec2 hz_uv;\n\
|
||||
layout(location = 2) in vec4 hz_color;\n\
|
||||
const int hz_corner[6] = int[6](0, 3, 2, 0, 2, 1);\n\
|
||||
int hz_vertex_id() {\n\
|
||||
int i = int(gl_VertexIndex) - int(gl_InstanceIndex);\n\
|
||||
return i / 6 * 4 + hz_corner[i % 6];\n\
|
||||
}",
|
||||
setup: "Position = vec3(hz_pos, 0.0); Color = hz_color; UV0 = hz_uv;",
|
||||
// GL's clip depth runs from -w to w, wgpu's from 0 to w; GUI text has no depth test.
|
||||
finish: "gl_Position.z = 0.5 * gl_Position.w;",
|
||||
vertex_id: "hz_vertex_id()",
|
||||
};
|
||||
|
||||
const VERTEX: &str = "assets/minecraft/shaders/core/text.vsh";
|
||||
const FRAGMENT: &str = "assets/minecraft/shaders/core/text.fsh";
|
||||
|
||||
/// The packs' text shader over `defaults` (the game's own resources, which have vanilla's and
|
||||
/// its includes), when a pack replaces either stage.
|
||||
pub(super) fn load(root: &Path, defaults: &Path) -> Option<Arc<CoreProgram>> {
|
||||
if root == defaults {
|
||||
return None;
|
||||
}
|
||||
let read = |base: &Path, path: &str| std::fs::read_to_string(base.join(path)).ok();
|
||||
let own =
|
||||
|path: &str| read(root, path).filter(|text| read(defaults, path).as_ref() != Some(text));
|
||||
let (own_vertex, own_fragment) = (own(VERTEX), own(FRAGMENT));
|
||||
if own_vertex.is_none() && own_fragment.is_none() {
|
||||
return None;
|
||||
}
|
||||
let vertex = own_vertex.or_else(|| read(defaults, VERTEX))?;
|
||||
let fragment = own_fragment.or_else(|| read(defaults, FRAGMENT))?;
|
||||
let include = |id: &str| {
|
||||
let (namespace, path) = id.split_once(':')?;
|
||||
let path = format!("assets/{namespace}/{path}");
|
||||
read(root, &path).or_else(|| read(defaults, &path))
|
||||
};
|
||||
match horizon_shaders::translate_core(&vertex, &fragment, &["IS_GUI"], &include, &GLUE) {
|
||||
Ok(program) => {
|
||||
log::info!("the resource pack's text shader draws the GUI's text");
|
||||
Some(Arc::new(program))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("the resource pack's text shader is not used: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
|
||||
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
|
||||
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
|
||||
|
||||
/// The pipeline and its resources.
|
||||
pub(super) struct PackText {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
group: wgpu::BindGroup,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl PackText {
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
atlas: &wgpu::TextureView,
|
||||
program: &CoreProgram,
|
||||
) -> Option<Self> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = |label, module: &naga::Module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(label),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
|
||||
})
|
||||
};
|
||||
let vertex = module("Resource pack text (vertex)", &program.vertex);
|
||||
let fragment = module("Resource pack text (fragment)", &program.fragment);
|
||||
let visibility = wgpu::ShaderStages::VERTEX_FRAGMENT;
|
||||
let mut entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
for (_, binding) in horizon_shaders::SAMPLERS {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
});
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: binding + 1,
|
||||
visibility,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
});
|
||||
}
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
entries: &entries,
|
||||
});
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Resource pack text uniforms"),
|
||||
size: 1024,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
// The font textures' nearest filtering; every sampler a pack names reads the atlas.
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
..Default::default()
|
||||
});
|
||||
let mut resources: Vec<wgpu::BindGroupEntry> = (0..4)
|
||||
.map(|i| wgpu::BindGroupEntry {
|
||||
binding: i as u32,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &uniform,
|
||||
offset: BLOCK_OFFSETS[i],
|
||||
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
for (_, binding) in horizon_shaders::SAMPLERS {
|
||||
resources.push(wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(atlas),
|
||||
});
|
||||
resources.push(wgpu::BindGroupEntry {
|
||||
binding: binding + 1,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
});
|
||||
}
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
layout: &layout,
|
||||
entries: &resources,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Resource pack text"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<HudVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
// BlendFunction.TRANSLUCENT.
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
log::warn!("the resource pack's text shader is not used: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
pipeline,
|
||||
group,
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
|
||||
/// The frame's blocks, as `GuiRenderer` sets them for the GUI.
|
||||
pub(super) fn prepare(
|
||||
&self,
|
||||
uploads: &mut crate::gfx::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
batch: &HudBatch,
|
||||
size: [u32; 2],
|
||||
) {
|
||||
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
|
||||
}
|
||||
|
||||
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
|
||||
/// pipeline and bind group again after.
|
||||
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.draw(range.clone(), range.start..range.start + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// The std140 blocks: `Globals` (`GlobalSettingsUniform.update`: the camera at the origin,
|
||||
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
|
||||
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
|
||||
/// moving 11000 back, colour modulator white) and an empty `Fog`.
|
||||
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
|
||||
let mut data = vec![0f32; 256];
|
||||
let globals = 0;
|
||||
data[globals + 3] = batch.glint_alpha;
|
||||
data[globals + 7] = batch.game_time;
|
||||
data[globals + 8] = size[0] as f32;
|
||||
data[globals + 9] = size[1] as f32;
|
||||
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
|
||||
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
|
||||
let (near, far) = (1000.0f32, 11000.0f32);
|
||||
let projection = 64;
|
||||
data[projection] = 2.0 / w;
|
||||
data[projection + 5] = -2.0 / h;
|
||||
data[projection + 10] = 2.0 / (near - far);
|
||||
data[projection + 12] = -1.0;
|
||||
data[projection + 13] = 1.0;
|
||||
data[projection + 14] = (far + near) / (near - far);
|
||||
data[projection + 15] = 1.0;
|
||||
let transforms = 128;
|
||||
for (matrix, z) in [(transforms, -11000.0), (transforms + 16, 0.0)] {
|
||||
data[matrix] = 1.0;
|
||||
data[matrix + 5] = 1.0;
|
||||
data[matrix + 10] = 1.0;
|
||||
data[matrix + 14] = z;
|
||||
data[matrix + 15] = 1.0;
|
||||
}
|
||||
data[transforms + 32..transforms + 36].fill(1.0);
|
||||
bytemuck::cast_slice(&data).to_vec()
|
||||
}
|
||||
|
||||
/// `range` split into runs of GUI text and of the rest, in order.
|
||||
pub(super) fn text_runs(
|
||||
vertices: &[HudVertex],
|
||||
range: std::ops::Range<u32>,
|
||||
) -> Vec<(std::ops::Range<u32>, bool)> {
|
||||
let mut runs: Vec<(std::ops::Range<u32>, bool)> = Vec::new();
|
||||
for i in range {
|
||||
let text = vertices.get(i as usize).is_some_and(|v| v.text != 0);
|
||||
match runs.last_mut() {
|
||||
Some((run, t)) if *t == text => run.end = i + 1,
|
||||
_ => runs.push((i..i + 1, text)),
|
||||
}
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
|
||||
let mut batch = HudBatch::new([1920, 1080]);
|
||||
batch.scale = 4.0;
|
||||
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
|
||||
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
|
||||
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
|
||||
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
|
||||
};
|
||||
// ProjMat * ModelViewMat * (x, y, 0, 1) for the GUI's corners: 480 x 270 GUI pixels.
|
||||
for ([x, y], [cx, cy]) in [([0.0, 0.0], [-1.0, 1.0]), ([480.0, 270.0], [1.0, -1.0])] {
|
||||
let clip = apply(64, apply(128, [x, y, 0.0, 1.0]));
|
||||
assert!(
|
||||
(clip[0] - cx).abs() < 1e-5 && (clip[1] - cy).abs() < 1e-5,
|
||||
"{clip:?}"
|
||||
);
|
||||
// At GL's far plane, as vanilla's GUI is.
|
||||
assert!((clip[2] - 1.0).abs() < 1e-4, "{clip:?}");
|
||||
}
|
||||
// A pack finds the GUI scale as ScreenSize.x * ProjMat[0][0] / 2.
|
||||
assert!((data[8] * data[64] / 2.0 - 4.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_splits_from_the_rest() {
|
||||
let vertex = |text| HudVertex {
|
||||
pos: [0.0; 2],
|
||||
uv: [0.0; 2],
|
||||
color: 0,
|
||||
depth: 0.0,
|
||||
glint_uv: [0.0; 2],
|
||||
text,
|
||||
};
|
||||
let vertices: Vec<HudVertex> = [0, 0, 1, 1, 1, 0].into_iter().map(vertex).collect();
|
||||
assert_eq!(
|
||||
text_runs(&vertices, 1..6),
|
||||
[(1..2, false), (2..5, true), (5..6, false)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
pub mod graphics_ui;
|
||||
pub mod hdr_calibration;
|
||||
pub mod hud;
|
||||
pub mod hud_layout;
|
||||
pub mod hud_overlays;
|
||||
|
||||
@@ -40,8 +40,17 @@ fn sampling_threads(cores: usize) -> usize {
|
||||
/// terrain starts one sample job at most this often and no detail: it would
|
||||
/// otherwise take the cores the near chunks and the frame need.
|
||||
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
/// The coarsest tile level: `Plan` roots cover at most 1024 chunks around.
|
||||
pub(crate) const MAX_LEVEL: u8 = 10;
|
||||
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
|
||||
/// around (a level-22 tile is 67 million blocks wide: the whole world).
|
||||
pub(crate) const MAX_LEVEL: u8 = 22;
|
||||
/// Tiles this far from the camera (1024 chunks, as far as far terrain went
|
||||
/// before it covered the world) are planned with `WORLD_SPLIT`.
|
||||
const WORLD_START: i32 = 1024 * 16;
|
||||
/// How many tile widths away from the camera a tile of the world-wide terrain
|
||||
/// splits (`Plan::visit`: cells then span about 1/48 of the distance).
|
||||
const WORLD_SPLIT: i32 = 3;
|
||||
/// The farthest `Plan` reaches, in chunks.
|
||||
pub const MAX_CHUNKS: u32 = 1 << 22;
|
||||
/// The coarsest tile level whose chunks are generated in full (trees,
|
||||
/// structures) rather than sampled: cells of up to 4 blocks, out to about
|
||||
/// 1024 blocks at Full far detail.
|
||||
@@ -417,7 +426,7 @@ impl Plan {
|
||||
elevation: i32,
|
||||
reduced: bool,
|
||||
) -> Self {
|
||||
let range = distance.min(1024) as i32 * 16;
|
||||
let range = distance.min(MAX_CHUNKS) as i32 * 16;
|
||||
let mut level = 0;
|
||||
while (16 << level) < range {
|
||||
level += 1;
|
||||
@@ -454,7 +463,16 @@ impl Plan {
|
||||
// Keep distant cells small on screen; finest tiles retain individual
|
||||
// blocks. Reduced keeps them twice as large (a quarter of the cells).
|
||||
let split = if previous.split.contains(&key) { 10 } else { 8 };
|
||||
let threshold = key.size() * if reduced { split / 2 } else { split };
|
||||
// Beyond the old 1024-chunk limit the cells are as large as `reduced`'s:
|
||||
// the whole world is 22 levels, not all of them worth full cells.
|
||||
let threshold = key.size()
|
||||
* if key.distance(camera) >= WORLD_START {
|
||||
WORLD_SPLIT
|
||||
} else if reduced {
|
||||
split / 2
|
||||
} else {
|
||||
split
|
||||
};
|
||||
if key.level > 0
|
||||
&& (key.distance(camera) < detail * 16 + 16
|
||||
|| key.distance(camera).max(self.elevation) < threshold)
|
||||
@@ -1744,6 +1762,42 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn world_wide_plans_stay_small_and_cover_every_level() {
|
||||
// About 10 000 nodes at a corner of the world, 9 000 at its centre.
|
||||
const WORLD_PLAN_NODES: usize = 16_384;
|
||||
for (camera, elevation) in [
|
||||
([0, 0], 0),
|
||||
([115, 135], 49_000_000),
|
||||
([-30_000_000, 29_999_000], 90),
|
||||
] {
|
||||
let plan =
|
||||
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, false);
|
||||
let reduced =
|
||||
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, true);
|
||||
let top = plan.nodes.iter().map(|k| k.level).max().unwrap();
|
||||
assert_eq!(top, MAX_LEVEL, "{camera:?} y={elevation}: roots stop short");
|
||||
assert!(
|
||||
plan.nodes.len() < WORLD_PLAN_NODES,
|
||||
"{camera:?} y={elevation}: {} nodes",
|
||||
plan.nodes.len()
|
||||
);
|
||||
assert!(
|
||||
reduced.nodes.len() <= plan.nodes.len(),
|
||||
"{camera:?} y={elevation}: reduced has {} nodes, full {}",
|
||||
reduced.nodes.len(),
|
||||
plan.nodes.len()
|
||||
);
|
||||
// Every level from the finest planned one up to the roots has tiles.
|
||||
let finest = plan.nodes.iter().map(|k| k.level).min().unwrap();
|
||||
for level in finest..=MAX_LEVEL {
|
||||
assert!(
|
||||
plan.nodes.iter().any(|k| k.level == level),
|
||||
"{camera:?} y={elevation}: no level-{level} tile"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
|
||||
let ground = Plan::new([0, 0], 12, 1024);
|
||||
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
|
||||
|
||||
@@ -304,6 +304,34 @@ fn mesh_columns_with_neighbors(
|
||||
});
|
||||
mesh_columns_with_edges(key, columns, covered, covered_bounds, &edges, &[], material)
|
||||
}
|
||||
/// From this tile level up a cell is 64 blocks wide or more: the strata
|
||||
/// inside the ground cannot be told apart, so a column is its surface.
|
||||
const HEIGHTMAP_LEVEL: u8 = 6;
|
||||
/// `column` as a heightmap: its top solid span reaching down to the bottom of
|
||||
/// the world (so the walls toward lower neighbours are one face of the
|
||||
/// surface's material, merged along the cliff), and the fluid over it.
|
||||
fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
|
||||
let Some((top, below)) = column.spans.split_last() else {
|
||||
return column.clone();
|
||||
};
|
||||
let ground = |span: &Span| Span {
|
||||
min: crate::chunk::MIN_Y as i16,
|
||||
..*span
|
||||
};
|
||||
let mut spans = Vec::with_capacity(2);
|
||||
match below.last() {
|
||||
Some(under) if fluid(top) => {
|
||||
spans.push(ground(under));
|
||||
spans.push(*top);
|
||||
}
|
||||
_ if fluid(top) => spans.push(*top),
|
||||
_ => spans.push(ground(top)),
|
||||
}
|
||||
Column {
|
||||
surface: column.surface,
|
||||
spans,
|
||||
}
|
||||
}
|
||||
fn mesh_columns_with_edges(
|
||||
key: TileKey,
|
||||
columns: &[Column],
|
||||
@@ -314,6 +342,28 @@ fn mesh_columns_with_edges(
|
||||
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
|
||||
) -> SectionMesh {
|
||||
assert_eq!(columns.len(), CELLS * CELLS);
|
||||
let heightmap_columns;
|
||||
let heightmap_neighbors;
|
||||
let (columns, neighbors) = if key.level >= HEIGHTMAP_LEVEL {
|
||||
let [ox, _, oz] = key.origin();
|
||||
let mut fluid = |span: &Span| material(span, Direction::Up, [ox, oz]).fluid != 0;
|
||||
heightmap_columns = columns
|
||||
.iter()
|
||||
.map(|column| heightmap(column, &mut fluid))
|
||||
.collect::<Vec<_>>();
|
||||
heightmap_neighbors = neighbors.each_ref().map(|side| {
|
||||
side.iter()
|
||||
.map(|edge| EdgeColumn {
|
||||
start: edge.start,
|
||||
end: edge.end,
|
||||
column: heightmap(&edge.column, &mut fluid),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
(&heightmap_columns[..], &heightmap_neighbors)
|
||||
} else {
|
||||
(columns, neighbors)
|
||||
};
|
||||
let mut count = CELLS * CELLS;
|
||||
let edge_offsets = std::array::from_fn::<_, 4, _>(|side| {
|
||||
let start = count;
|
||||
@@ -336,7 +386,10 @@ fn mesh_columns_with_edges(
|
||||
step = step.min(other.size());
|
||||
}
|
||||
}
|
||||
step
|
||||
// The cells of the coarsest tiles are thousands of blocks wide: a
|
||||
// few steps each keep the work bounded, the cut around finer terrain
|
||||
// coarse for as long as such a tile stands in for its children.
|
||||
step.max(stride / MAX_STEPS)
|
||||
};
|
||||
let filled = fill_buried_gaps(
|
||||
columns,
|
||||
@@ -944,20 +997,35 @@ fn merge(rects: &mut Vec<Rect>) {
|
||||
/// The widest face `emit` writes, in blocks: its UVs, counted from the face's
|
||||
/// corner, must stay within `TerrainVertex::tiled`'s range.
|
||||
const MAX_FACE: f32 = 1024.0;
|
||||
/// A face that would be more than this many `MAX_FACE` pieces is one quad
|
||||
/// whose UVs are scaled to fit: a cell of the coarsest tiles is thousands to
|
||||
/// millions of blocks wide, and the texture repeats within it are far below a
|
||||
/// pixel.
|
||||
const MAX_PIECES: f32 = 4.0;
|
||||
/// Steps along a cell of a tile cut around finer terrain, at most.
|
||||
const MAX_STEPS: i32 = 32;
|
||||
fn emit(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
|
||||
let pieces = ((rect.u1 - rect.u0) / MAX_FACE).ceil() * ((rect.v1 - rect.v0) / MAX_FACE).ceil();
|
||||
if pieces > MAX_PIECES {
|
||||
let extent = (rect.u1 - rect.u0).max(rect.v1 - rect.v0);
|
||||
emit_quad(mesh, key, rect, (MAX_FACE / extent).min(1.0));
|
||||
return;
|
||||
}
|
||||
let mut u0 = rect.u0;
|
||||
while u0 < rect.u1 {
|
||||
let u1 = rect.u1.min(u0 + MAX_FACE);
|
||||
let mut v0 = rect.v0;
|
||||
while v0 < rect.v1 {
|
||||
let v1 = rect.v1.min(v0 + MAX_FACE);
|
||||
emit_quad(mesh, key, Rect { u0, v0, u1, v1 });
|
||||
emit_quad(mesh, key, Rect { u0, v0, u1, v1 }, 1.0);
|
||||
v0 = v1;
|
||||
}
|
||||
u0 = u1;
|
||||
}
|
||||
}
|
||||
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
|
||||
/// `uv_scale` shrinks the UVs, in blocks from the face's corner, of a face
|
||||
/// too wide for them.
|
||||
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect, uv_scale: f32) {
|
||||
let Rect { u0, v0, u1, v1 } = rect;
|
||||
let p = f32::from_bits(key.plane);
|
||||
let mut points = match key.direction {
|
||||
@@ -994,7 +1062,10 @@ fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
|
||||
out.extend(points.into_iter().zip(uvs).map(|(pos, uv)| {
|
||||
TerrainVertex::tiled(
|
||||
pos,
|
||||
[uv[0] - corner[0], uv[1] - corner[1]],
|
||||
[
|
||||
(uv[0] - corner[0]) * uv_scale,
|
||||
(uv[1] - corner[1]) * uv_scale,
|
||||
],
|
||||
key.layer,
|
||||
key.color,
|
||||
key.light,
|
||||
@@ -1093,6 +1164,84 @@ mod tests {
|
||||
}
|
||||
assert_eq!(area, 2500.0 * 15.5);
|
||||
}
|
||||
#[test]
|
||||
fn far_columns_are_heightmaps_of_their_surface_and_water() {
|
||||
let fluid = |s: &Span| s.state.0 == 2;
|
||||
let strata = Column {
|
||||
surface: None,
|
||||
spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)],
|
||||
};
|
||||
let flat = heightmap(&strata, fluid);
|
||||
assert_eq!(flat.spans, [span(-64, 64, 4)]);
|
||||
let sea = Column {
|
||||
surface: None,
|
||||
spans: vec![span(0, 50, 1), span(50, 62, 2)],
|
||||
};
|
||||
let flat = heightmap(&sea, fluid);
|
||||
assert_eq!(flat.spans, [span(-64, 50, 1), span(50, 62, 2)]);
|
||||
assert!(heightmap(&columns()[0], fluid).spans.is_empty());
|
||||
// A rolling landscape of strata: far tiles need a fraction of the quads.
|
||||
let rolling: Vec<Column> = (0..CELLS * CELLS)
|
||||
.map(|i| {
|
||||
let top = 64 + (i * 7 % 5) as i16;
|
||||
Column {
|
||||
surface: None,
|
||||
spans: vec![
|
||||
span(0, top - 4, 1),
|
||||
span(top - 4, top - 1, 3),
|
||||
span(top - 1, top, 4),
|
||||
],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let quads = |level| {
|
||||
let key = TileKey { x: 0, z: 0, level };
|
||||
let mesh = mesh_columns(key, &rolling, &FxHashSet::default(), None, material);
|
||||
mesh.solid.len() / 4
|
||||
};
|
||||
let (voxel, height) = (quads(HEIGHTMAP_LEVEL - 1), quads(HEIGHTMAP_LEVEL));
|
||||
assert!(height * 3 < voxel * 2, "{height} quads against {voxel}");
|
||||
}
|
||||
#[test]
|
||||
fn world_sized_faces_are_one_quad_with_uvs_that_fit() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
let key = FaceKey {
|
||||
direction: Direction::Up,
|
||||
plane: 64.0f32.to_bits(),
|
||||
overlay: 0,
|
||||
layer: 1,
|
||||
light: 0,
|
||||
color: 0,
|
||||
translucent: false,
|
||||
};
|
||||
let size = 4_194_304.0;
|
||||
emit(
|
||||
&mut mesh,
|
||||
key,
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: size,
|
||||
v1: size / 2.0,
|
||||
},
|
||||
);
|
||||
assert_eq!(mesh.solid.len(), 4);
|
||||
let corners: Vec<[f32; 3]> = mesh.solid.iter().map(|v| v.pos).collect();
|
||||
assert!(
|
||||
corners.iter().any(|p| p[0] == size && p[2] == size / 2.0),
|
||||
"{corners:?}"
|
||||
);
|
||||
let top = mesh
|
||||
.solid
|
||||
.iter()
|
||||
.map(tiled_uv)
|
||||
.fold(0.0f32, |m, uv| m.max(uv[0]).max(uv[1]));
|
||||
assert!(top <= MAX_FACE + 1.0, "UV reaches {top}");
|
||||
assert!(
|
||||
top > MAX_FACE / 4.0,
|
||||
"the texture still repeats across the face"
|
||||
);
|
||||
}
|
||||
fn normal(q: &[TerrainVertex; 4]) -> glam::Vec3 {
|
||||
let p = |i: usize| glam::Vec3::from(q[i].pos);
|
||||
(p(1) - p(0)).cross(p(2) - p(0))
|
||||
|
||||
@@ -545,7 +545,12 @@ impl MeshTables {
|
||||
});
|
||||
let mut face = [0u8; 6];
|
||||
let mut cover = [0f32; 6];
|
||||
for d in Direction::ALL {
|
||||
// A server's visibility knows no state (`FaceVisibility::empty`):
|
||||
// it meshes no fluid, so these stay unset.
|
||||
for d in Direction::ALL
|
||||
.into_iter()
|
||||
.filter(|_| !visibility.is_empty())
|
||||
{
|
||||
let (face_id, shape) = visibility
|
||||
.occlusion_face(BlockStateId(id as u32), d)
|
||||
.ok_or("missing occlusion face")?;
|
||||
|
||||
@@ -1178,6 +1178,8 @@ pub enum Page {
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
GraphicsHdr,
|
||||
/// HDR calibration, full screen (`calibration`).
|
||||
HdrCalibration,
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
@@ -1356,6 +1358,10 @@ pub struct OptionsUi {
|
||||
/// Scroll the selected language into the middle on the next layout.
|
||||
center_language: bool,
|
||||
pub graphics: crate::graphics_ui::GraphicsUi,
|
||||
/// HDR calibration while its page is open.
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
|
||||
pub calibration_fullscreen: Option<bool>,
|
||||
/// The AI Agent page's status lines (set by the game while it shows).
|
||||
pub agent_status: Vec<String>,
|
||||
}
|
||||
@@ -1391,6 +1397,8 @@ impl Default for OptionsUi {
|
||||
in_world: false,
|
||||
center_language: false,
|
||||
graphics: Default::default(),
|
||||
calibration: None,
|
||||
calibration_fullscreen: None,
|
||||
agent_status: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1602,7 +1610,9 @@ impl OptionsUi {
|
||||
Page::Mods => "horizon.pages.mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "horizon.pages.shader_packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
|
||||
"options.videoTitle"
|
||||
}
|
||||
Page::Controller => "horizon.pages.controller",
|
||||
Page::PadBinds => "horizon.pages.controller_binds",
|
||||
Page::Extras => "horizon.pages.extras",
|
||||
@@ -2611,6 +2621,12 @@ impl OptionsUi {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &self.calibration
|
||||
{
|
||||
c.draw(batch, atlas, cursor);
|
||||
return;
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
self.graphics.binding = self.selected_key;
|
||||
self.graphics
|
||||
@@ -2870,6 +2886,15 @@ impl OptionsUi {
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &mut self.calibration
|
||||
{
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
let outcome = c.click((x, y), size, drag);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if let Some(level) = Self::graphics_level(self.page()) {
|
||||
use crate::graphics_ui::Action;
|
||||
// Waiting on a key: a mouse button binds, as on Key Binds.
|
||||
@@ -2893,6 +2918,10 @@ impl OptionsUi {
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::BindKey(name) => self.selected_key = Some(name),
|
||||
Action::Calibrate => {
|
||||
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
|
||||
self.push(Page::HdrCalibration);
|
||||
}
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -3119,7 +3148,8 @@ impl OptionsUi {
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
if slider || graphics_slider {
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -3277,6 +3307,37 @@ impl OptionsUi {
|
||||
self.pack_screen.as_mut().expect("made above")
|
||||
}
|
||||
/// Escape or Done: leave the current page, applying page results.
|
||||
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
|
||||
/// set before; both go back to Graphics: HDR.
|
||||
fn calibration_outcome(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
outcome: crate::hdr_calibration::Outcome,
|
||||
) -> Vec<Event> {
|
||||
use crate::hdr_calibration::Outcome;
|
||||
match outcome {
|
||||
Outcome::Nothing | Outcome::Changed => Vec::new(),
|
||||
Outcome::Click => vec![Event::ButtonClick],
|
||||
Outcome::Save | Outcome::Cancel => {
|
||||
if let Some(c) = self.calibration.take()
|
||||
&& outcome == Outcome::Save
|
||||
{
|
||||
c.save(s);
|
||||
}
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
events.extend(self.back(s));
|
||||
events
|
||||
}
|
||||
}
|
||||
}
|
||||
/// HDR calibration's test picture, while its page is open: the step, its nits and
|
||||
/// where the controls start (a share of the height `h`).
|
||||
pub fn calibration_picture(&self, h: f32) -> Option<(crate::hdr_calibration::Step, f32, f32)> {
|
||||
self.calibration
|
||||
.as_ref()
|
||||
.filter(|_| self.page() == Page::HdrCalibration)
|
||||
.map(|c| c.picture(h))
|
||||
}
|
||||
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
let page = self.stack.pop().unwrap_or(Page::Main);
|
||||
@@ -3408,6 +3469,12 @@ impl OptionsUi {
|
||||
text: Option<&str>,
|
||||
) -> Vec<Event> {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
if self.page() == Page::HdrCalibration
|
||||
&& let Some(c) = &mut self.calibration
|
||||
{
|
||||
let outcome = c.key(key);
|
||||
return self.calibration_outcome(s, outcome);
|
||||
}
|
||||
if self.resource_error.is_some() {
|
||||
if matches!(key, K::Escape | K::Enter | K::NumpadEnter) {
|
||||
self.resource_error = None;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! outline, nether portal swirl). Positions are clip-space quads; textures
|
||||
//! come from the shared 2D atlas and may repeat.
|
||||
use crate::{
|
||||
effects::EffectInstance,
|
||||
hud::{Rect, TextureFlags},
|
||||
hud_layout::GuiDraw,
|
||||
};
|
||||
@@ -271,6 +272,28 @@ impl ScreenEffects {
|
||||
}
|
||||
}
|
||||
|
||||
/// The nausea effect's blend-out advance: over its last `NAUSEA_BLEND_OUT`
|
||||
/// ticks it has no effect (`MobEffects.NAUSEA`, `endsWithin(60)`).
|
||||
const NAUSEA_BLEND_OUT: i32 = 60;
|
||||
|
||||
/// `MobEffectInstance.BlendState.hasEffect`: still past the blend-out point,
|
||||
/// so the effect counts as on (`!endsWithin(60)` for `MobEffects.NAUSEA`).
|
||||
pub fn nausea_has_effect(e: &EffectInstance) -> bool {
|
||||
!e.ends_within(NAUSEA_BLEND_OUT)
|
||||
}
|
||||
|
||||
/// A new nausea instance's `BlendState`: from nothing, or, when the server
|
||||
/// asked for no blending, at once (`MobEffectInstance.skipBlending`'s
|
||||
/// `BlendState.setImmediate`).
|
||||
pub fn nausea_blend_added(e: &EffectInstance, blend: bool) -> (f32, f32) {
|
||||
let factor = if blend || !nausea_has_effect(e) {
|
||||
0.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
(factor, factor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -97,6 +97,50 @@ pub fn profile_skin_url(profile: &serde_json::Value) -> Option<String> {
|
||||
let (value, signature) = parse_profile(profile).textures?;
|
||||
unpack(&value, signature, &TEXTURE_URIS).skin
|
||||
}
|
||||
|
||||
/// The textures of a Minecraft services `/minecraft/profile` answer: the
|
||||
/// `ACTIVE` skin and cape (`MinecraftProfileResponse`). Unsigned, like the
|
||||
/// local player's own textures, and only URLs under the allowed domains count.
|
||||
pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
|
||||
let active = |key: &str| {
|
||||
profile
|
||||
.get(key)?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|t| t.get("state").and_then(|s| s.as_str()) == Some("ACTIVE"))
|
||||
};
|
||||
let skin_entry = active("skins");
|
||||
let skin = skin_entry
|
||||
.and_then(|t| t.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|url| {
|
||||
TEXTURE_URIS
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
|
||||
})
|
||||
.map(str::to_owned);
|
||||
let slim = skin_entry
|
||||
.and_then(|t| t.get("variant"))
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("slim"));
|
||||
let cape = active("capes")
|
||||
.and_then(|t| t.get("url"))
|
||||
.and_then(|u| u.as_str())
|
||||
.filter(|url| {
|
||||
TEXTURE_URIS
|
||||
.iter()
|
||||
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
|
||||
})
|
||||
.map(str::to_owned);
|
||||
Textures {
|
||||
skin,
|
||||
slim,
|
||||
cape,
|
||||
elytra: None,
|
||||
signed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `profile_skin_url` on a block entity's `profile` NBT (`SkullBlockEntity`):
|
||||
/// the same `properties` list, held as a compound. A name-only profile (a
|
||||
/// string tag) has no properties and no skin URL.
|
||||
@@ -1033,6 +1077,36 @@ mod tests {
|
||||
assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn services_profile_answers_give_the_active_skin_and_cape() {
|
||||
let profile = serde_json::json!({
|
||||
"skins": [
|
||||
{"state": "INACTIVE", "url": "https://textures.minecraft.net/texture/old"},
|
||||
{"state": "ACTIVE", "url": "https://textures.minecraft.net/texture/skin1", "variant": "CLASSIC"}
|
||||
],
|
||||
"capes": [{"state": "ACTIVE", "url": "http://textures.minecraft.net/texture/cape1"}]
|
||||
});
|
||||
let t = super::services_profile_textures(&profile);
|
||||
assert_eq!(
|
||||
t.skin.as_deref(),
|
||||
Some("https://textures.minecraft.net/texture/skin1")
|
||||
);
|
||||
assert_eq!(
|
||||
t.cape.as_deref(),
|
||||
Some("http://textures.minecraft.net/texture/cape1")
|
||||
);
|
||||
assert!(!t.signed);
|
||||
let elsewhere = serde_json::json!({"skins": [{"state": "ACTIVE", "url": "https://evil.example/texture/x"}]});
|
||||
assert_eq!(
|
||||
super::services_profile_textures(&elsewhere),
|
||||
super::Textures::default()
|
||||
);
|
||||
assert_eq!(
|
||||
super::services_profile_textures(&serde_json::json!({})),
|
||||
super::Textures::default()
|
||||
);
|
||||
}
|
||||
|
||||
/// `getByName` and `getProfileById` answer from the services the
|
||||
/// discovery document names; a profile resolves through both.
|
||||
#[test]
|
||||
|
||||
@@ -196,5 +196,8 @@
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads that generate, light and mesh chunks. Next start.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph."
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.loading.opening_world": "Opening world",
|
||||
"horizon.loading.cancelling_world": "Cancelling world"
|
||||
}
|
||||
@@ -11,5 +11,8 @@
|
||||
"horizon.options.look_deadzone": "Zona muerta de vista",
|
||||
"horizon.options.button_glyphs": "Símbolos de botones",
|
||||
"horizon.options.menu_navigation": "Navegación de menús",
|
||||
"horizon.options.on_screen_keyboard": "Teclado en pantalla"
|
||||
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
|
||||
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
|
||||
"horizon.loading.opening_world": "Abriendo el mundo",
|
||||
"horizon.loading.cancelling_world": "Cancelando"
|
||||
}
|
||||
@@ -11,5 +11,8 @@
|
||||
"horizon.options.look_deadzone": "Martwa strefa rozglądania",
|
||||
"horizon.options.button_glyphs": "Symbole przycisków",
|
||||
"horizon.options.menu_navigation": "Nawigacja po menu",
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa"
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie"
|
||||
}
|
||||
@@ -195,5 +195,8 @@
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Потоки, которые генерируют, освещают и строят чанки. Со следующего запуска.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, при сбое — DirectX 12 (Windows) или OpenGL (Linux). Со следующего запуска.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком."
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.loading.opening_world": "Открытие мира",
|
||||
"horizon.loading.cancelling_world": "Отмена"
|
||||
}
|
||||
@@ -11,5 +11,8 @@
|
||||
"horizon.options.look_deadzone": "Мертва зона огляду",
|
||||
"horizon.options.button_glyphs": "Значки кнопок",
|
||||
"horizon.options.menu_navigation": "Навігація в меню",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура"
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування"
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"horizon.options.controller": "手柄",
|
||||
"horizon.options.vibration": "振动",
|
||||
"horizon.options.adaptive_triggers": "自适应扳机",
|
||||
"horizon.options.lightbar": "光条",
|
||||
"horizon.options.look_sensitivity": "视角灵敏度",
|
||||
"horizon.options.invert_x": "反转视角 X 轴",
|
||||
"horizon.options.invert_y": "反转视角 Y 轴",
|
||||
"horizon.options.sneak_button": "潜行按钮",
|
||||
"horizon.options.move_deadzone": "移动摇杆死区",
|
||||
"horizon.options.look_deadzone": "视角摇杆死区",
|
||||
"horizon.options.button_glyphs": "按键图标",
|
||||
"horizon.options.menu_navigation": "菜单导航",
|
||||
"horizon.options.on_screen_keyboard": "虚拟键盘",
|
||||
"horizon.options.chat_delay": "聊天延迟",
|
||||
"horizon.options.lightbar.health": "生命值",
|
||||
"horizon.options.lightbar.dimension": "维度",
|
||||
"horizon.options.glyphs.auto": "自动",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "焦点",
|
||||
"horizon.options.menu_pointer": "指针",
|
||||
"horizon.options.keyboard.auto": "自动",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "此功能在 Horizon 中尚不可用。",
|
||||
"horizon.options.search": "搜索设置…",
|
||||
"horizon.pages.mods": "模组",
|
||||
"horizon.pages.shader_packs": "光影包",
|
||||
"horizon.pages.controller": "手柄设置",
|
||||
"horizon.pages.controller_binds": "手柄按键绑定",
|
||||
"horizon.pages.extras": "Horizon 附加功能",
|
||||
"horizon.pages.settings": "Horizon 设置",
|
||||
"horizon.pages.agent": "AI 代理",
|
||||
"horizon.pages.settings.tooltip": "Horizon 自带的设置:图形、手柄、附加功能和模组。",
|
||||
"horizon.pages.graphics.tooltip": "简单的图形设置:每一项都标明了它的开销。",
|
||||
"horizon.pages.graphics_advanced.tooltip": "每一项图形设置,包括 Horizon 自带的,并标明各自的开销。",
|
||||
"horizon.pages.controller.tooltip": "手柄:按键、摇杆、振动和菜单导航。",
|
||||
"horizon.pages.extras.tooltip": "可选的附加功能,默认全部关闭:不开启时游戏保持原版行为。",
|
||||
"horizon.pages.agent.tooltip": "让你的 AI 代理来玩:本机上的 horizon-ctl 和 MCP 客户端。",
|
||||
"horizon.pages.shader_packs.tooltip": "来自 shaderpacks 文件夹的光影包:Horizon 自带格式与 shaders/ 格式。同一时间只运行一个。",
|
||||
"horizon.pages.mods.tooltip": "为 Horizon 制作的模组,来自 horizon-mods 文件夹。每个模组都在沙盒中运行,开启前保持关闭。",
|
||||
"horizon.pages.open.graphics": "图形…",
|
||||
"horizon.pages.open.graphics_advanced": "高级图形…",
|
||||
"horizon.pages.open.shader_packs": "光影包…",
|
||||
"horizon.pages.open.controller": "手柄设置…",
|
||||
"horizon.pages.open.extras": "附加功能…",
|
||||
"horizon.pages.open.mods": "模组…",
|
||||
"horizon.pages.open.agent": "AI 代理…",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon 图形…",
|
||||
"horizon.pages.open.controller_binds": "手柄按键绑定…",
|
||||
"horizon.pages.open.horizon_settings": "Horizon 设置…",
|
||||
"horizon.mods.empty": "没有模组:请将 .hmod 文件放入 horizon-mods 文件夹",
|
||||
"horizon.mods.stopped": "(已停止)",
|
||||
"horizon.mods.not_built": "(未构建)",
|
||||
"horizon.mods.dev": "(开发版)",
|
||||
"horizon.mods.settings": "设置",
|
||||
"horizon.mods.open_folder": "打开模组文件夹",
|
||||
"horizon.shader_packs.empty": "没有光影包:请将其放入 shaderpacks 文件夹",
|
||||
"horizon.shader_packs.kind_horizon": "(Horizon)",
|
||||
"horizon.agent.copy_details": "复制连接详情",
|
||||
"horizon.multiplayer.join": "加入",
|
||||
"horizon.multiplayer.cancel_reconnecting": "取消重新连接",
|
||||
"horizon.multiplayer.join_friend": "加入好友",
|
||||
"horizon.multiplayer.invite_code": "邀请码",
|
||||
"horizon.multiplayer.relay": "中继(可选)",
|
||||
"Preset": "预设",
|
||||
"LOD Distance": "LOD 距离",
|
||||
"Far Detail": "远景细节",
|
||||
"Cave Culling": "洞穴剔除",
|
||||
"Entity Culling": "实体剔除",
|
||||
"Chunk Updates": "区块更新",
|
||||
"Upload Budget": "上传预算",
|
||||
"Sky": "天空",
|
||||
"Fog": "雾",
|
||||
"Weather": "天气",
|
||||
"Animated Textures": "动画纹理",
|
||||
"Present Mode": "呈现模式",
|
||||
"Frame Pacing": "帧节奏",
|
||||
"Worker Threads": "工作线程",
|
||||
"Resolution Scale": "渲染比例",
|
||||
"Anti-aliasing": "抗锯齿",
|
||||
"Upscaling": "放大",
|
||||
"HDR Settings...": "HDR 设置…",
|
||||
"Paper White": "纸白亮度",
|
||||
"Peak Brightness": "峰值亮度",
|
||||
"Black Level": "黑位",
|
||||
"UI Brightness": "界面亮度",
|
||||
"Battery Mode": "电池模式",
|
||||
"Graphics API": "图形 API",
|
||||
"Performance Overlay": "性能叠加层",
|
||||
"Full Detail Distance": "全细节距离",
|
||||
"On": "开",
|
||||
"Off": "关",
|
||||
"Unlimited": "无限制",
|
||||
"Auto": "自动",
|
||||
"Custom": "自定义",
|
||||
"Performance": "性能",
|
||||
"Balanced": "均衡",
|
||||
"Quality": "质量",
|
||||
"Ultra": "超高",
|
||||
"Ultra Performance": "极致性能",
|
||||
"Native": "原生",
|
||||
"Full": "完全",
|
||||
"Reduced": "精简",
|
||||
"Efficient": "高效",
|
||||
"Precise": "精确",
|
||||
"Auto (VSync)": "自动(垂直同步)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "邮箱",
|
||||
"Immediate": "立即",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS 与图表",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "无开销",
|
||||
"Low cost": "低开销",
|
||||
"Medium cost": "中等开销",
|
||||
"High cost": "高开销",
|
||||
"Saves time": "节省时间",
|
||||
"Rendering": "渲染",
|
||||
"Display": "显示",
|
||||
"Preferences": "偏好",
|
||||
"Overlay": "叠加层",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "图形",
|
||||
"Graphics: Advanced": "图形:高级",
|
||||
"Graphics: HDR": "图形:HDR",
|
||||
"Advanced...": "高级…",
|
||||
"Simple...": "简单…",
|
||||
"Done": "完成",
|
||||
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
|
||||
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
|
||||
"Turn HDR on to use it.": "开启 HDR 后才能使用。",
|
||||
"%s chunks": "%s 个区块",
|
||||
"%s per frame": "每帧 %s",
|
||||
"%s MiB/frame": "每帧 %s MiB",
|
||||
"%s KiB/frame": "每帧 %s KiB",
|
||||
"%s nits": "%s 尼特",
|
||||
"Auto (%s)": "自动(%s)",
|
||||
"Auto (%s nits)": "自动(%s 尼特)",
|
||||
"%.2f nits": "%.2f 尼特",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "完整细节的地形范围。LOD 距离可以让本地世界延伸得更远。",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "本地世界的远景粗略地形。集成显卡最多 128 个区块。",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "“精简”把远景画得更粗略:在这块集成显卡上更快。",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "“精简”把远景画得更粗略,供带不动它的显卡使用。",
|
||||
"Field of view. Wider shows more terrain at once.": "视野。越宽同一时间看到的地形越多。",
|
||||
"Caps frames per second; a cap saves power and heat.": "限制每秒帧数;限制帧率可以省电降温。",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "等待显示器的刷新:无画面撕裂,略有延迟。",
|
||||
"Size of menus and the HUD.": "菜单与 HUD 的大小。",
|
||||
"Sets the options below together. Changing one makes it Custom.": "一起设置下面的选项。单独改动其中一项会变成“自定义”。",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "熔岩、火与阳光比白色更亮。需要 HDR 显示器。",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "显示器最深的黑色。OLED 为 0;液晶屏会微微发灰。",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "高光能有多亮。“自动”使用系统报告的峰值。",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "白纸与菜单的亮度。“自动”使用你桌面的白色。",
|
||||
"How bright the HUD and menus are, against paper white.": "HUD 和菜单相对纸白的亮度。",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "隐藏不透明方块后面的模型。实体仍会更新。自定义资源包时应关闭。",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
|
||||
"Blends grass and water colours across biomes when building chunks.": "构建区块时在群系之间平滑过渡草地与水的颜色。",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "云层:关闭、平面(快速)或 3D(高品质)。",
|
||||
"How far clouds reach, in chunks.": "云能延伸多远,以区块计。",
|
||||
"Draws the sky, sun, moon and stars.": "绘制天空、太阳、月亮和星星。",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "距离雾。关闭后能看到已加载世界的边缘。",
|
||||
"Draws rain and snow.": "绘制雨和雪。",
|
||||
"How far rain and snow fall around you.": "你周围雨雪落下的范围。",
|
||||
"How many particles effects spawn.": "效果生成的粒子数量。",
|
||||
"Round shadows under mobs and items.": "生物和物品脚下的圆形阴影。",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "树叶带透光空隙。关闭后画成实心。",
|
||||
"Sorts water and glass more exactly behind each other.": "更精确地排序互相重叠的水与玻璃。",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "哪些区块重建要等这一帧完成:无、附近的,或你身边的。",
|
||||
"Animates water, lava, fire and other moving textures.": "让水、熔岩、火等动态纹理动起来。",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "每个屏幕像素对应的画面像素。低于 100% 时 GPU 更省力。",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "平滑锯齿边缘。FXAA 更柔和;TAA 最平滑,但移动物体可能拖影。",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "以更小的尺寸渲染世界再用 TAA 重建。更快,但更模糊。",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "在 HDR 显示器上输出 HDR:熔岩、火与阳光更亮。",
|
||||
"How frames reach the screen while VSync is off.": "关闭垂直同步时帧画面如何送到屏幕。",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "“高效”会休眠到下一帧;“精确”持续空转,与原版相同。",
|
||||
"Fills the screen (borderless).": "填满整个屏幕(无边框)。",
|
||||
"Brightens dark places.": "提亮黑暗的地方。",
|
||||
"Lowers the frame rate when minimised, or idle too.": "最小化或空闲时降低帧率。",
|
||||
"Sways the view while walking.": "行走时晃动视角。",
|
||||
"Fades newly loaded chunks in.": "新加载的区块淡入显示。",
|
||||
"Darkens the screen's edges.": "加深屏幕边缘的暗角。",
|
||||
"Shows when the world saves.": "显示世界保存的时机。",
|
||||
"Shows attack cooldown.": "显示攻击冷却。",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "限制 60 FPS(菜单或失焦时 30),工作线程减半:更省电。",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "负责生成、光照与网格化区块的线程数。下次启动生效。",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "优先 Vulkan,失败时用 DirectX 12(Windows)或 OpenGL(Linux)。下次启动生效。",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
|
||||
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
|
||||
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
|
||||
"horizon.loading.opening_world": "正在打开世界",
|
||||
"horizon.loading.cancelling_world": "正在取消"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The stacks passed to item models for an entity's two arms.
|
||||
use horizon_client::{
|
||||
Result,
|
||||
items::ItemStack,
|
||||
mobs::{Extra, render},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{BufRead, BufReader, BufWriter, Write},
|
||||
};
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let mut out = BufWriter::new(File::create(&args[2])?);
|
||||
for line in BufReader::new(File::open(&args[1])?).lines() {
|
||||
let case: Value = serde_json::from_str(&line?)?;
|
||||
let mut extra = Extra::default();
|
||||
let main = case["main"].as_str().ok_or("main")?;
|
||||
if main != "air" {
|
||||
extra.set_main_hand_stack(Some(ItemStack::new(&format!("minecraft:{main}"), 1)));
|
||||
}
|
||||
let off = case["off"].as_str().ok_or("off")?;
|
||||
if off != "air" {
|
||||
extra.off_hand = Some(format!("minecraft:{off}").into());
|
||||
}
|
||||
let left = case["left"].as_bool().ok_or("left")?;
|
||||
for physical_left in [false, true] {
|
||||
let stack = render::held_item_stack(&extra, physical_left != left);
|
||||
writeln!(
|
||||
out,
|
||||
"{}",
|
||||
stack.as_ref().map_or("minecraft:air", |s| &s.item)
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -48,6 +48,7 @@ mod frame_limiter_probe;
|
||||
mod game_loop_probe;
|
||||
mod ghost_recipe_fixture;
|
||||
mod held_drop_fixture;
|
||||
mod held_item_stack_probe;
|
||||
mod hotbar_fixture;
|
||||
mod hud_fixture;
|
||||
mod input_probe;
|
||||
@@ -101,6 +102,7 @@ mod tooltip_display_fixture;
|
||||
mod trading_fixture;
|
||||
mod translucency_fixture;
|
||||
mod trident_probe;
|
||||
mod vehicle_container_fixture;
|
||||
mod weather_fixture;
|
||||
mod world_border_render_fixture;
|
||||
mod world_options_fixture;
|
||||
@@ -144,6 +146,7 @@ probes! {
|
||||
game_loop_probe,
|
||||
ghost_recipe_fixture,
|
||||
held_drop_fixture,
|
||||
held_item_stack_probe,
|
||||
hotbar_fixture,
|
||||
hud_fixture,
|
||||
input_probe,
|
||||
@@ -196,6 +199,7 @@ probes! {
|
||||
trading_fixture,
|
||||
trident_probe,
|
||||
translucency_fixture,
|
||||
vehicle_container_fixture,
|
||||
weather_fixture,
|
||||
world_border_render_fixture,
|
||||
world_options_fixture,
|
||||
|
||||
@@ -529,8 +529,12 @@ impl HiveHost for ProbeHive<'_, '_> {
|
||||
}
|
||||
fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<Mob> {
|
||||
let index = self.first + self.added.len();
|
||||
let mut m =
|
||||
horizon_client::entity_nbt::mob_from_nbt(tag, self.types, 100_000 + index as u32)?;
|
||||
let mut m = horizon_client::entity_nbt::mob_from_nbt(
|
||||
tag,
|
||||
self.types,
|
||||
100_000 + index as u32,
|
||||
None,
|
||||
)?;
|
||||
if m.species() != Species::Bee {
|
||||
return None;
|
||||
}
|
||||
@@ -1180,6 +1184,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
|
||||
@@ -1426,7 +1431,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None);
|
||||
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None, None);
|
||||
let flower = horizon_client::mobs::bee::get(&mobs[i]).and_then(|b| b.flower);
|
||||
let mut honey = Vec::new();
|
||||
let stored = {
|
||||
|
||||
@@ -353,8 +353,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
if let Tag::Compound(c) = &mut tag {
|
||||
c.insert("id".into(), Tag::String(id.into()));
|
||||
}
|
||||
let mob = entity_nbt::mob_from_nbt(&tag, &types, 1).ok_or("unreadable villager")?;
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None);
|
||||
let mob =
|
||||
entity_nbt::mob_from_nbt(&tag, &types, 1, None).ok_or("unreadable villager")?;
|
||||
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None);
|
||||
writeln!(out, "{name} snbt {text}")?;
|
||||
let mut kept = horizon_world::nbt::Compound::new();
|
||||
for key in [
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! The menu a container vehicle opens for tools/test_vehicle_container_parity.py:
|
||||
//! reads one entity type id per line and prints the menu type its `createMenu`
|
||||
//! builds (`ChestMenu.threeRows` for a chest boat or chest minecart,
|
||||
//! `HopperMenu` for a hopper minecart - `horizon_client::containers::vehicle_menu`,
|
||||
//! the menu `lan_open_vehicle_container` sends), its container size, and the
|
||||
//! title's name key (`MenuProvider.getDisplayName` = `Entity.getDisplayName`).
|
||||
//!
|
||||
//! usage: vehicle_container_fixture INPUT OUTPUT
|
||||
use horizon_client::containers::{vehicle_menu, vehicle_menu_name};
|
||||
use std::fs;
|
||||
|
||||
fn case(entity_type: &str) -> Option<String> {
|
||||
let entity_type = entity_type.trim();
|
||||
let (menu, size) = vehicle_menu(entity_type)?;
|
||||
Some(format!("{menu} {size} {}", vehicle_menu_name(entity_type)))
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: vehicle_container_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -35,9 +35,19 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
|
||||
}
|
||||
|
||||
/// `EntityTypes.OP_ONLY_CUSTOM_DATA`: the entity types whose data only a player
|
||||
/// who can use game master blocks sets (`EntityType.onlyOpCanSetNbt`).
|
||||
fn egg_op_only(id: &str) -> bool {
|
||||
matches!(
|
||||
id,
|
||||
"minecraft:falling_block"
|
||||
| "minecraft:command_block_minecart"
|
||||
| "minecraft:spawner_minecart"
|
||||
)
|
||||
}
|
||||
|
||||
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
|
||||
/// permission: a spawn egg carries data of an op-only entity type
|
||||
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
|
||||
/// permission: a spawn egg carries data of an op-only entity type.
|
||||
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
stack.item.ends_with("_spawn_egg")
|
||||
&& matches!(
|
||||
@@ -45,12 +55,7 @@ pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:entity_data"))
|
||||
.and_then(|data| data.get("id")),
|
||||
Some(Tag::String(id)) if matches!(
|
||||
id.to_string_lossy().as_str(),
|
||||
"minecraft:falling_block"
|
||||
| "minecraft:command_block_minecart"
|
||||
| "minecraft:spawner_minecart"
|
||||
)
|
||||
Some(Tag::String(id)) if egg_op_only(&id.to_string_lossy())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,6 +81,24 @@ pub fn spawn_egg_on_spawner(
|
||||
(id, data.filter(|_| op).cloned())
|
||||
}
|
||||
|
||||
/// `EntityType.updateCustomEntityTag`: the `minecraft:entity_data` a spawn egg
|
||||
/// puts on the mob it spawned — only when the data names the spawned type (data
|
||||
/// without an `id` is the egg's own), and an op-only type's data only for an op.
|
||||
pub fn spawn_egg_entity_data<'a>(
|
||||
stack: &'a crate::items::ItemStack,
|
||||
spawned: &str,
|
||||
op: bool,
|
||||
) -> Option<&'a Tag> {
|
||||
let data = stack.data()?.other("minecraft:entity_data")?;
|
||||
if matches!(data.get("id"), Some(Tag::String(id)) if id.to_string_lossy() != spawned) {
|
||||
return None;
|
||||
}
|
||||
if egg_op_only(spawned) && !op {
|
||||
return None;
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
|
||||
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
|
||||
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
|
||||
/// spawners alike): the entity type a spawner item spawns.
|
||||
@@ -517,6 +540,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spawn_egg_data_needs_its_own_type_and_an_op_for_an_op_only_one() {
|
||||
let egg = |entity_data: &str| {
|
||||
let components = snbt(&format!("{{\"minecraft:entity_data\":{entity_data}}}"));
|
||||
let (damage, extra) = crate::item_data::decode_components(Some(&components));
|
||||
crate::items::ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..crate::items::ItemStack::new("minecraft:zombie_spawn_egg", 1)
|
||||
}
|
||||
};
|
||||
// `entity_data` without an id names the egg's own type: applied for anyone.
|
||||
let own = egg("{NoAI:1b}");
|
||||
assert_eq!(
|
||||
spawn_egg_entity_data(&own, "minecraft:zombie", false)
|
||||
.and_then(|data| data.get("NoAI")),
|
||||
Some(&Tag::Byte(1))
|
||||
);
|
||||
// `entity_data.id` names another type: the spawned type keeps no data.
|
||||
let other = egg("{id:\"minecraft:pig\",NoAI:1b}");
|
||||
assert_eq!(
|
||||
spawn_egg_entity_data(&other, "minecraft:zombie", false),
|
||||
None
|
||||
);
|
||||
// An op-only type's data needs an op.
|
||||
assert_eq!(
|
||||
spawn_egg_entity_data(&own, "minecraft:falling_block", false),
|
||||
None
|
||||
);
|
||||
assert!(spawn_egg_entity_data(&own, "minecraft:falling_block", true).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_data_names_its_spawn_and_op_only_types() {
|
||||
let item = |text: &str| {
|
||||
|
||||
@@ -25,32 +25,47 @@ static PENDING: Mutex<Pending> = Mutex::new(Pending {
|
||||
save_failure: None,
|
||||
});
|
||||
|
||||
impl Pending {
|
||||
/// `reportChunkLoadFailure` after `warnOnLowDiskSpace` found `low_disk_space`.
|
||||
fn load_failed(&mut self, x: i32, z: i32, low_disk_space: bool) {
|
||||
self.low_disk_space |= low_disk_space;
|
||||
self.load_failure = Some((x, z));
|
||||
}
|
||||
|
||||
/// `reportChunkSaveFailure` after `warnOnLowDiskSpace` found `low_disk_space`.
|
||||
fn save_failed(&mut self, x: i32, z: i32, low_disk_space: bool) {
|
||||
self.low_disk_space |= low_disk_space;
|
||||
self.save_failure = Some((x, z));
|
||||
}
|
||||
}
|
||||
|
||||
fn with(update: impl FnOnce(&mut Pending)) {
|
||||
let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
|
||||
update(&mut pending);
|
||||
}
|
||||
|
||||
/// `MinecraftServer.warnOnLowDiskSpace` for the world folder `dir`.
|
||||
fn warn_on_low_disk_space(dir: &Path) {
|
||||
if save::low_disk_space(dir) {
|
||||
fn warn_on_low_disk_space(dir: &Path) -> bool {
|
||||
let low = save::low_disk_space(dir);
|
||||
if low {
|
||||
// `MinecraftServer.sendLowDiskSpaceWarning`.
|
||||
log::warn!("Low disk space! Might not be able to save the world.");
|
||||
with(|p| p.low_disk_space = true);
|
||||
}
|
||||
low
|
||||
}
|
||||
|
||||
/// `reportChunkLoadFailure` for the chunk at (`x`, `z`) of the dimension in `dir`.
|
||||
pub fn report_load_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
|
||||
log::error!("Failed to load chunk {x},{z}: {error}");
|
||||
warn_on_low_disk_space(dir);
|
||||
with(|p| p.load_failure = Some((x, z)));
|
||||
let low = warn_on_low_disk_space(dir);
|
||||
with(|p| p.load_failed(x, z, low));
|
||||
}
|
||||
|
||||
/// `reportChunkSaveFailure`.
|
||||
pub fn report_save_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
|
||||
log::error!("Failed to save chunk {x},{z}: {error}");
|
||||
warn_on_low_disk_space(dir);
|
||||
with(|p| p.save_failure = Some((x, z)));
|
||||
let low = warn_on_low_disk_space(dir);
|
||||
with(|p| p.save_failed(x, z, low));
|
||||
}
|
||||
|
||||
/// `Minecraft.sendLowDiskSpaceWarning`: a server's `ClientboundLowDiskSpaceWarningPacket`
|
||||
@@ -103,28 +118,40 @@ pub fn save_failure_toast(x: i32, z: i32) -> SystemToast {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// These tests keep off `PENDING` where they can: the save tests run in
|
||||
// the same process and report their own chunk failures into it (#4854).
|
||||
#[test]
|
||||
fn failures_keep_the_newest_chunk_of_each_kind() {
|
||||
let _ = take();
|
||||
let missing = Path::new("/nonexistent-horizon-world");
|
||||
report_load_failure(missing, 1, 2, &"a");
|
||||
report_load_failure(missing, 3, 4, &"b");
|
||||
report_save_failure(missing, 5, 6, &"c");
|
||||
// A folder that cannot be asked has no low-space warning.
|
||||
let mut pending = Pending::default();
|
||||
pending.load_failed(1, 2, false);
|
||||
pending.load_failed(3, 4, false);
|
||||
pending.save_failed(5, 6, false);
|
||||
assert_eq!(
|
||||
take(),
|
||||
pending,
|
||||
Pending {
|
||||
low_disk_space: false,
|
||||
load_failure: Some((3, 4)),
|
||||
save_failure: Some((5, 6)),
|
||||
}
|
||||
);
|
||||
assert_eq!(take(), Pending::default());
|
||||
// A later failure on a full disk raises the warning, and one with
|
||||
// space left does not lower it.
|
||||
pending.save_failed(7, 8, true);
|
||||
pending.load_failed(9, 10, false);
|
||||
assert_eq!(
|
||||
pending,
|
||||
Pending {
|
||||
low_disk_space: true,
|
||||
load_failure: Some((9, 10)),
|
||||
save_failure: Some((7, 8)),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// The only test that drains `PENDING`; other tests only add to it, so
|
||||
/// the flag it raises is still there when it takes it.
|
||||
#[test]
|
||||
fn a_servers_warning_packet_toasts_low_disk_space() {
|
||||
let _ = take();
|
||||
report_low_disk_space();
|
||||
assert!(take().low_disk_space);
|
||||
}
|
||||
|
||||
@@ -85,8 +85,9 @@ impl Placement {
|
||||
self.slots.is_empty()
|
||||
}
|
||||
}
|
||||
/// A recipe result: `ItemStackTemplate` (`id`, `count`, `components`), with the
|
||||
/// id optional for the transmuting recipes that keep the input's item.
|
||||
/// A recipe result: `ItemStackTemplate` (`id`, `count`, `components`), or a bare item id
|
||||
/// (`ItemStackTemplate.CODEC` falls back to `Item.CODEC`), with the id optional for the
|
||||
/// transmuting recipes that keep the input's item.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Template {
|
||||
pub item: Option<Arc<str>>,
|
||||
@@ -113,7 +114,7 @@ impl Template {
|
||||
None => (0, None),
|
||||
};
|
||||
Some(Self {
|
||||
item: v["id"].as_str().map(Arc::from),
|
||||
item: horizon_world::loot::recipe_result_id(v).map(Arc::from),
|
||||
count: template_count(v)?,
|
||||
damage,
|
||||
extra,
|
||||
@@ -1112,6 +1113,48 @@ pub fn recipe_result(v: &Value) -> Option<ItemStack> {
|
||||
let template = Template::parse(v)?;
|
||||
template.item.is_some().then(|| template.create())
|
||||
}
|
||||
/// The `.json` files under a `recipe` folder, its subfolders' too, in id
|
||||
/// order: each one's path inside the folder without the extension (`a/b`, as a
|
||||
/// recipe id has it) and its file. An error when `dir` itself can't be read; a
|
||||
/// subfolder or an entry that can't be listed is warned and skipped, as
|
||||
/// vanilla's `PathPackResources` keeps what it found.
|
||||
pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![fs::read_dir(dir)?];
|
||||
while let Some(entries) = stack.pop() {
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
match fs::read_dir(&path) {
|
||||
Ok(list) => stack.push(list),
|
||||
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
|
||||
}
|
||||
} else if path.extension().is_some_and(|e| e == "json") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut named: Vec<(String, PathBuf)> = files
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
|
||||
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
|
||||
(name.join("/"), path)
|
||||
})
|
||||
.collect();
|
||||
// Vanilla lists resources by id, in a map keyed by it
|
||||
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
|
||||
// a path's components put the subfolder's file first.
|
||||
named.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(named)
|
||||
}
|
||||
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
|
||||
fn custom_type(v: &Value) -> Option<&str> {
|
||||
v["type"]
|
||||
@@ -1207,31 +1250,16 @@ impl Recipes {
|
||||
let mut cooking = Vec::new();
|
||||
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
|
||||
let mut meta = HashMap::new();
|
||||
let json_files = |dir: &Path| -> Result<Vec<PathBuf>> {
|
||||
let mut files: Vec<_> = fs::read_dir(dir)?
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "json"))
|
||||
.collect();
|
||||
files.sort();
|
||||
Ok(files)
|
||||
};
|
||||
let stem = |p: &Path| {
|
||||
p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_owned()
|
||||
};
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
for p in json_files(&root.join("data/minecraft/recipe"))? {
|
||||
sources.push((stem(&p), fs::read(&p)?));
|
||||
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
|
||||
sources.push((name, fs::read(&p)?));
|
||||
}
|
||||
for namespace in crate::mod_content::namespaces(root) {
|
||||
let dir = root.join(format!("data/{namespace}/recipe"));
|
||||
if dir.is_dir() {
|
||||
for p in json_files(&dir)? {
|
||||
sources.push((format!("{namespace}:{}", stem(&p)), fs::read(&p)?));
|
||||
for (name, p) in recipe_files(&dir)? {
|
||||
sources.push((format!("{namespace}:{name}"), fs::read(&p)?));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1334,7 +1362,7 @@ impl Recipes {
|
||||
}
|
||||
"minecraft:crafting_special_bannerduplicate" => special(
|
||||
ing("banner")
|
||||
.zip(v["result"]["id"].as_str())
|
||||
.zip(horizon_world::loot::recipe_result_id(&v["result"]))
|
||||
.map(|(banner, id)| Special::BannerDuplicate {
|
||||
banner,
|
||||
result: id.to_owned(),
|
||||
@@ -1343,7 +1371,7 @@ impl Recipes {
|
||||
"minecraft:crafting_special_shielddecoration" => special(
|
||||
ing("banner")
|
||||
.zip(ing("target"))
|
||||
.zip(v["result"]["id"].as_str())
|
||||
.zip(horizon_world::loot::recipe_result_id(&v["result"]))
|
||||
.map(|((banner, target), id)| Special::ShieldDecoration {
|
||||
banner,
|
||||
target,
|
||||
@@ -1820,6 +1848,12 @@ mod tests {
|
||||
file("mymod:mixer", "c"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(root.join("data/mymod/recipe/deep/er")).unwrap();
|
||||
fs::write(
|
||||
root.join("data/mymod/recipe/deep/er/d.json"),
|
||||
file("mymod:mixer", "d"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/later.json"),
|
||||
file("minecraft:from_a_later_version", "x"),
|
||||
@@ -1831,7 +1865,7 @@ mod tests {
|
||||
recipes.custom[kind].iter().map(|r| r.id.clone()).collect()
|
||||
};
|
||||
assert_eq!(ids("mymod:crusher"), ["mymod:a", "mymod:b"]);
|
||||
assert_eq!(ids("mymod:mixer"), ["mymod:c"]);
|
||||
assert_eq!(ids("mymod:mixer"), ["mymod:c", "mymod:deep/er/d"]);
|
||||
assert_eq!(recipes.custom.len(), 2);
|
||||
// Not crafted, and no recipe-book meta of their own.
|
||||
assert!(recipes.crafting.is_empty() && recipes.cooking.is_empty());
|
||||
@@ -1868,6 +1902,33 @@ mod tests {
|
||||
assert!(result(r#"{"id":"minecraft:iron_ingot","count":0}"#).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn a_recipe_result_can_be_a_bare_item_id() {
|
||||
// `ItemStackTemplate.CODEC` falls back to `Item.CODEC`: `"result": "mod:item"` is one item.
|
||||
let result = |json: &str| recipe_result(&serde_json::from_str(json).unwrap());
|
||||
let stack = result(r#""ruby:ruby""#).unwrap();
|
||||
assert_eq!((&*stack.item, stack.count), ("ruby:ruby", 1));
|
||||
// The `item` key of older versions is no `id`, and a number names no item.
|
||||
assert!(result(r#"{"item":"ruby:ruby"}"#).is_none());
|
||||
assert!(result("42").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn a_shapeless_recipe_makes_the_item_its_bare_result_names() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-bare-result-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/bare.json"),
|
||||
br#"{"type":"minecraft:crafting_shapeless","ingredients":["minecraft:flint"],"result":"minecraft:stick"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = Items::for_tests(HashMap::new());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let grid = [Some(ItemStack::new("minecraft:flint", 1)), None, None, None];
|
||||
let (_, made) = recipes.find(&grid, 2).unwrap();
|
||||
assert_eq!((&*made.item, made.count), ("minecraft:stick", 1));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn cook_finds_the_first_recipe_the_scan_finds() {
|
||||
let recipes = cooking_book();
|
||||
for kind in [
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
//! The debug screen's entries (26.3 `DebugScreenEntries`, `DebugScreenEntryList`):
|
||||
//! what each F3 line block and debug renderer is, whether it shows always,
|
||||
//! only in the overlay or never, the two built-in profiles, and
|
||||
//! `debug-profile.json` beside `options.txt`, which keeps them.
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `DebugScreenEntryStatus`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
AlwaysOn,
|
||||
InOverlay,
|
||||
Never,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
/// `getSerializedName`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Status::AlwaysOn => "alwaysOn",
|
||||
Status::InOverlay => "inOverlay",
|
||||
Status::Never => "never",
|
||||
}
|
||||
}
|
||||
fn parse(name: &str) -> Option<Self> {
|
||||
[Status::AlwaysOn, Status::InOverlay, Status::Never]
|
||||
.into_iter()
|
||||
.find(|s| s.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// `DebugScreenProfile`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Profile {
|
||||
Default,
|
||||
Performance,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Profile::Default => "default",
|
||||
Profile::Performance => "performance",
|
||||
}
|
||||
}
|
||||
pub fn translation_key(self) -> &'static str {
|
||||
match self {
|
||||
Profile::Default => "debug.options.profile.default",
|
||||
Profile::Performance => "debug.options.profile.performance",
|
||||
}
|
||||
}
|
||||
fn parse(name: &str) -> Option<Self> {
|
||||
[Profile::Default, Profile::Performance]
|
||||
.into_iter()
|
||||
.find(|p| p.name() == name)
|
||||
}
|
||||
/// `DebugScreenEntries.PROFILES`.
|
||||
fn statuses(self) -> &'static [(&'static str, Status)] {
|
||||
use Status::*;
|
||||
match self {
|
||||
Profile::Default => &[
|
||||
("3d_crosshair", InOverlay),
|
||||
("game_version", InOverlay),
|
||||
("tps", InOverlay),
|
||||
("fps", InOverlay),
|
||||
("memory", InOverlay),
|
||||
("system_specs", InOverlay),
|
||||
("player_position", InOverlay),
|
||||
("player_section_position", InOverlay),
|
||||
("simple_performance_impactors", InOverlay),
|
||||
],
|
||||
Profile::Performance => &[
|
||||
("tps", InOverlay),
|
||||
("fps", AlwaysOn),
|
||||
("gpu_utilization", InOverlay),
|
||||
("memory", InOverlay),
|
||||
("simple_performance_impactors", InOverlay),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `DebugEntryCategory`: the screen's two sections, by `sortKey`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Category {
|
||||
/// `SCREEN_TEXT`, `debug.options.category.text`.
|
||||
Text,
|
||||
/// `RENDERER` (`DebugEntryNoop`), `debug.options.category.renderer`.
|
||||
Renderer,
|
||||
}
|
||||
|
||||
impl Category {
|
||||
pub fn translation_key(self) -> &'static str {
|
||||
match self {
|
||||
Category::Text => "debug.options.category.text",
|
||||
Category::Renderer => "debug.options.category.renderer",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One registered entry: its path in the `minecraft` namespace, its section,
|
||||
/// and whether it shows while debug info is reduced (`isAllowed(true)`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Entry {
|
||||
pub path: &'static str,
|
||||
pub category: Category,
|
||||
pub reduced: bool,
|
||||
}
|
||||
|
||||
const fn text(path: &'static str, reduced: bool) -> Entry {
|
||||
Entry {
|
||||
path,
|
||||
category: Category::Text,
|
||||
reduced,
|
||||
}
|
||||
}
|
||||
const fn renderer(path: &'static str) -> Entry {
|
||||
Entry {
|
||||
path,
|
||||
category: Category::Renderer,
|
||||
reduced: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `DebugScreenEntries`' registrations, in their order.
|
||||
pub const ENTRIES: [Entry; 46] = [
|
||||
text("game_version", true),
|
||||
text("fps", true),
|
||||
text("tps", true),
|
||||
text("memory", true),
|
||||
text("detailed_memory", true),
|
||||
text("system_specs", true),
|
||||
text("looking_at_block_state", false),
|
||||
text("looking_at_block_tags", false),
|
||||
text("looking_at_fluid_state", false),
|
||||
text("looking_at_fluid_tags", false),
|
||||
text("looking_at_entity", false),
|
||||
text("looking_at_entity_tags", false),
|
||||
text("chunk_render_stats", true),
|
||||
text("chunk_generation_stats", false),
|
||||
text("entity_render_stats", true),
|
||||
text("particle_render_stats", false),
|
||||
text("chunk_source_stats", true),
|
||||
text("player_position", false),
|
||||
text("player_section_position", true),
|
||||
text("player_speed", false),
|
||||
text("light_levels", false),
|
||||
text("heightmap", false),
|
||||
text("biome", false),
|
||||
text("local_difficulty", false),
|
||||
text("day_count", false),
|
||||
text("entity_spawn_counts", false),
|
||||
text("sound_mood", false),
|
||||
text("sound_cache", true),
|
||||
text("post_effects", false),
|
||||
renderer("entity_hitboxes"),
|
||||
renderer("chunk_borders"),
|
||||
renderer("3d_crosshair"),
|
||||
renderer("chunk_section_paths"),
|
||||
text("gpu_utilization", true),
|
||||
text("simple_performance_impactors", true),
|
||||
renderer("chunk_section_octree"),
|
||||
renderer("visualize_water_levels"),
|
||||
renderer("visualize_heightmap"),
|
||||
renderer("visualize_collision_boxes"),
|
||||
renderer("visualize_entity_supporting_blocks"),
|
||||
renderer("visualize_block_light_levels"),
|
||||
renderer("visualize_sky_light_levels"),
|
||||
renderer("visualize_solid_faces"),
|
||||
renderer("visualize_chunks_on_server"),
|
||||
renderer("visualize_sky_light_sections"),
|
||||
renderer("chunk_section_visibility"),
|
||||
];
|
||||
|
||||
/// `DebugScreenEntries.getEntry` by path.
|
||||
pub fn entry(path: &str) -> Option<&'static Entry> {
|
||||
ENTRIES.iter().find(|e| e.path == path)
|
||||
}
|
||||
|
||||
/// `DebugOptionsScreen.OptionList.updateSearch`: the entries whose path
|
||||
/// contains `search`, by category then identifier.
|
||||
pub fn search(search: &str) -> Vec<&'static Entry> {
|
||||
let mut all: Vec<&Entry> = ENTRIES.iter().filter(|e| e.path.contains(search)).collect();
|
||||
all.sort_by(|a, b| a.category.cmp(&b.category).then(a.path.cmp(b.path)));
|
||||
all
|
||||
}
|
||||
|
||||
/// `DebugScreenEntryList`: every entry's status (by full identifier, as the
|
||||
/// file keeps them) and the profile they came from if unchanged. Whether the
|
||||
/// F3 overlay is open (`isOverlayVisible`) is the game's `debug`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DebugEntries {
|
||||
statuses: BTreeMap<String, Status>,
|
||||
profile: Option<Profile>,
|
||||
file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for DebugEntries {
|
||||
fn default() -> Self {
|
||||
let mut entries = Self {
|
||||
statuses: BTreeMap::new(),
|
||||
profile: None,
|
||||
file: None,
|
||||
};
|
||||
entries.reset_to_profile(Profile::Default);
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier(path: &str) -> String {
|
||||
format!("minecraft:{path}")
|
||||
}
|
||||
|
||||
impl DebugEntries {
|
||||
/// `DebugScreenEntryList.load` from `debug-profile.json` in `dir`: the
|
||||
/// default profile without a file; a file that does not parse is reset
|
||||
/// to it and saved again.
|
||||
pub fn load(dir: &Path) -> Self {
|
||||
let file = dir.join("debug-profile.json");
|
||||
let mut entries = Self {
|
||||
file: Some(file.clone()),
|
||||
..Self::default()
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&file) else {
|
||||
return entries;
|
||||
};
|
||||
match Self::parse(&text) {
|
||||
Some((Some(profile), _)) => entries.reset_to_profile(profile),
|
||||
Some((None, custom)) => {
|
||||
entries.statuses = custom;
|
||||
entries.profile = None;
|
||||
}
|
||||
None => {
|
||||
log::error!(
|
||||
"Couldn't read debug profile file {}, resetting to default",
|
||||
file.display()
|
||||
);
|
||||
entries.save();
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
/// `SerializedOptions.CODEC`: an optional `profile` name and an optional
|
||||
/// `custom` map of identifiers to statuses.
|
||||
fn parse(text: &str) -> Option<(Option<Profile>, BTreeMap<String, Status>)> {
|
||||
let json: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let object = json.as_object()?;
|
||||
let profile = match object.get("profile") {
|
||||
Some(name) => Some(Profile::parse(name.as_str()?)?),
|
||||
None => None,
|
||||
};
|
||||
let mut custom = BTreeMap::new();
|
||||
if let Some(map) = object.get("custom") {
|
||||
for (key, status) in map.as_object()? {
|
||||
// Identifier.CODEC: a bare path is in the minecraft namespace.
|
||||
let key = if key.contains(':') {
|
||||
key.clone()
|
||||
} else {
|
||||
identifier(key)
|
||||
};
|
||||
custom.insert(key, Status::parse(status.as_str()?)?);
|
||||
}
|
||||
}
|
||||
Some((profile, custom))
|
||||
}
|
||||
/// `DebugScreenEntryList.save`: the profile's name while it is unchanged,
|
||||
/// else every status, with the data version the codec wraps it in.
|
||||
pub fn save(&self) {
|
||||
let Some(file) = &self.file else {
|
||||
return;
|
||||
};
|
||||
let mut json = serde_json::Map::new();
|
||||
json.insert(
|
||||
"DataVersion".into(),
|
||||
horizon_core::version::DATA_VERSION.into(),
|
||||
);
|
||||
match self.profile {
|
||||
Some(profile) => {
|
||||
json.insert("profile".into(), profile.name().into());
|
||||
}
|
||||
None => {
|
||||
let custom: serde_json::Map<String, serde_json::Value> = self
|
||||
.statuses
|
||||
.iter()
|
||||
.map(|(k, s)| (k.clone(), s.name().into()))
|
||||
.collect();
|
||||
json.insert("custom".into(), custom.into());
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(file, serde_json::Value::Object(json).to_string()) {
|
||||
log::error!("Failed to save debug profile file {}: {e}", file.display());
|
||||
}
|
||||
}
|
||||
fn reset_to_profile(&mut self, profile: Profile) {
|
||||
self.profile = Some(profile);
|
||||
self.statuses = profile
|
||||
.statuses()
|
||||
.iter()
|
||||
.map(|(path, status)| (identifier(path), *status))
|
||||
.collect();
|
||||
}
|
||||
/// `loadProfile` then `save` (the screen's profile buttons).
|
||||
pub fn load_profile(&mut self, profile: Profile) {
|
||||
self.reset_to_profile(profile);
|
||||
self.save();
|
||||
}
|
||||
pub fn is_using_profile(&self, profile: Profile) -> bool {
|
||||
self.profile == Some(profile)
|
||||
}
|
||||
/// `getStatus`: never for an entry the statuses leave out.
|
||||
pub fn status(&self, path: &str) -> Status {
|
||||
self.statuses
|
||||
.get(&identifier(path))
|
||||
.copied()
|
||||
.unwrap_or(Status::Never)
|
||||
}
|
||||
/// `setStatus`: the statuses are custom from now on, and saved.
|
||||
pub fn set_status(&mut self, path: &str, status: Status) {
|
||||
self.profile = None;
|
||||
self.statuses.insert(identifier(path), status);
|
||||
self.save();
|
||||
}
|
||||
/// `toggleStatus` (F3+B, F3+G) with the overlay open or not: whether the
|
||||
/// entry is on afterwards.
|
||||
pub fn toggle_status(&mut self, path: &str, overlay: bool) -> bool {
|
||||
let (status, on) = match self.statuses.get(&identifier(path)) {
|
||||
Some(Status::AlwaysOn) => (Status::Never, false),
|
||||
Some(Status::InOverlay) if overlay => (Status::Never, false),
|
||||
Some(Status::Never) if overlay => (Status::InOverlay, true),
|
||||
_ => (Status::AlwaysOn, true),
|
||||
};
|
||||
self.set_status(path, status);
|
||||
on
|
||||
}
|
||||
/// `isCurrentlyEnabled` (`rebuildCurrentList`): on always, or in the
|
||||
/// `overlay` while it is open, and allowed with the debug info `reduced`.
|
||||
pub fn enabled(&self, path: &str, overlay: bool, reduced: bool) -> bool {
|
||||
let shown = match self.status(path) {
|
||||
Status::AlwaysOn => true,
|
||||
Status::InOverlay => overlay,
|
||||
Status::Never => false,
|
||||
};
|
||||
shown && entry(path).is_some_and(|e| e.reduced || !reduced)
|
||||
}
|
||||
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw.
|
||||
pub fn any_enabled(&self, overlay: bool, reduced: bool) -> bool {
|
||||
ENTRIES
|
||||
.iter()
|
||||
.any(|e| self.enabled(e.path, overlay, reduced))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn toggling_follows_the_overlay() {
|
||||
let mut e = DebugEntries::default();
|
||||
// Not in the default profile: on always while F3 is closed.
|
||||
assert!(e.toggle_status("entity_hitboxes", false));
|
||||
assert_eq!(e.status("entity_hitboxes"), Status::AlwaysOn);
|
||||
assert!(!e.toggle_status("entity_hitboxes", false));
|
||||
assert_eq!(e.status("entity_hitboxes"), Status::Never);
|
||||
// With F3 open, a hidden entry goes in the overlay only.
|
||||
assert!(e.toggle_status("chunk_borders", true));
|
||||
assert_eq!(e.status("chunk_borders"), Status::AlwaysOn);
|
||||
assert!(!e.toggle_status("chunk_borders", true));
|
||||
assert_eq!(e.status("chunk_borders"), Status::Never);
|
||||
assert!(e.toggle_status("chunk_borders", true));
|
||||
assert_eq!(e.status("chunk_borders"), Status::InOverlay);
|
||||
assert!(!e.toggle_status("chunk_borders", true));
|
||||
assert!(!e.is_using_profile(Profile::Default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_needs_the_overlay_and_full_debug_info() {
|
||||
let mut e = DebugEntries::default();
|
||||
assert!(!e.any_enabled(false, false));
|
||||
assert!(e.enabled("player_position", true, false));
|
||||
// Reduced debug info hides the position, not the section.
|
||||
assert!(!e.enabled("player_position", true, true));
|
||||
assert!(e.enabled("player_section_position", true, true));
|
||||
e.load_profile(Profile::Performance);
|
||||
assert!(e.enabled("fps", false, false));
|
||||
assert!(!e.enabled("memory", false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_keeps_a_profile_or_the_statuses() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-debug-profile-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut e = DebugEntries::load(&dir);
|
||||
assert!(e.is_using_profile(Profile::Default));
|
||||
e.load_profile(Profile::Performance);
|
||||
let saved: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join("debug-profile.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(saved["profile"], "performance");
|
||||
assert!(DebugEntries::load(&dir).is_using_profile(Profile::Performance));
|
||||
e.set_status("biome", Status::AlwaysOn);
|
||||
let loaded = DebugEntries::load(&dir);
|
||||
assert_eq!(loaded.status("biome"), Status::AlwaysOn);
|
||||
assert_eq!(loaded.status("fps"), Status::AlwaysOn);
|
||||
assert!(!loaded.is_using_profile(Profile::Performance));
|
||||
// A broken file is reset to the default profile.
|
||||
std::fs::write(dir.join("debug-profile.json"), "{\"profile\":\"nope\"}").unwrap();
|
||||
assert!(DebugEntries::load(&dir).is_using_profile(Profile::Default));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_sorts_text_before_renderers() {
|
||||
let all = search("");
|
||||
assert_eq!(all.len(), 46);
|
||||
assert_eq!(all[0].path, "biome");
|
||||
let first_renderer = all
|
||||
.iter()
|
||||
.position(|e| e.category == Category::Renderer)
|
||||
.unwrap();
|
||||
assert_eq!(all[first_renderer].path, "3d_crosshair");
|
||||
assert!(
|
||||
all[first_renderer..]
|
||||
.iter()
|
||||
.all(|e| e.category == Category::Renderer)
|
||||
);
|
||||
assert_eq!(search("chunk_b").len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1004,49 +1004,45 @@ impl ArmorStand {
|
||||
Vec3::new(p.x + w, p.y + h, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `isInWater`: some water block of the stand's box has its surface
|
||||
/// (`FluidState.getHeight`, a full block under more water) at or above the
|
||||
/// box's bottom, as `Entity.updateFluidHeightAndDoFluidPushing` counts it.
|
||||
pub fn in_water(&self, world: &World, fluids: &crate::motion::FluidResources) -> bool {
|
||||
let (lo, hi) = (self.bounds().min_position(), self.bounds().max_position());
|
||||
let water = |x: i32, y: i32, z: i32| {
|
||||
let state = fluids.fluid(world.block(x, y, z));
|
||||
(state.kind() == horizon_world::fluid::FluidKind::Water).then_some(state)
|
||||
};
|
||||
(lo.x.floor() as i32..=hi.x.floor() as i32).any(|x| {
|
||||
(lo.y.floor() as i32..hi.y.ceil() as i32).any(|y| {
|
||||
(lo.z.floor() as i32..=hi.z.floor() as i32).any(|z| {
|
||||
water(x, y, z).is_some_and(|state| {
|
||||
let height = if water(x, y + 1, z).is_some() {
|
||||
1.0
|
||||
} else {
|
||||
state.own_height()
|
||||
};
|
||||
f64::from(y) + f64::from(height) >= lo.y
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
/// LivingEntity.travel (armor stands have no other movement): move by
|
||||
/// the velocity (a landing stops the fall,
|
||||
/// `Block.updateEntityMovementAfterFallOn`), let the blocks it is inside
|
||||
/// LivingEntity.travel (armor stands have no other movement): clamp tiny
|
||||
/// velocity components (`LivingEntity.aiStep`), move by the velocity (a
|
||||
/// landing stops the fall, `Block.updateEntityMovementAfterFallOn`, and a
|
||||
/// wall collision zeros that horizontal axis), let the blocks it is inside
|
||||
/// push it (`push`: a bubble column), then in water the 0.8 slowdown and
|
||||
/// `getFluidFallingAdjustedMovement`, in air gravity 0.08 and the 0.98
|
||||
/// drag, so a stand at rest on the ground keeps -0.0784 as vanilla's.
|
||||
/// `getFluidFallingAdjustedMovement`, in air gravity 0.08, ground/air
|
||||
/// horizontal drag (`friction * 0.91`), and the 0.98 vertical drag, so a
|
||||
/// stand at rest on the ground keeps -0.0784 as vanilla's.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
scene: &mut crate::collisions::SceneCollisions,
|
||||
facts: &BlockFacts,
|
||||
in_water: bool,
|
||||
push: &mut dyn FnMut(Aabb, &mut Vec3),
|
||||
) {
|
||||
if self.marker {
|
||||
return;
|
||||
}
|
||||
let wanted = self.velocity;
|
||||
// `travelInFluid` reads `isFalling` before the move and any push.
|
||||
let v = self.velocity;
|
||||
let wanted = Vec3::new(
|
||||
if v.x.abs() < 0.003 { 0.0 } else { v.x },
|
||||
if v.y.abs() < 0.003 { 0.0 } else { v.y },
|
||||
if v.z.abs() < 0.003 { 0.0 } else { v.z },
|
||||
);
|
||||
self.velocity = wanted;
|
||||
// `travelInFluid` reads `isFalling` before the move and any push;
|
||||
// `travelInAir` reads ground friction before the move.
|
||||
let falling = wanted.y <= 0.0;
|
||||
let mut y = wanted.y;
|
||||
let friction = if !in_water && self.on_ground {
|
||||
let below = scene.world.block(
|
||||
self.position.x.floor() as i32,
|
||||
(self.position.y - 0.500_001).floor() as i32,
|
||||
self.position.z.floor() as i32,
|
||||
);
|
||||
facts.get(below).friction
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mut velocity = wanted;
|
||||
if let Ok(moved) = scene.resolve(self.bounds(), wanted, 0.0, self.on_ground) {
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -1054,26 +1050,37 @@ impl ArmorStand {
|
||||
self.position.z + moved.z,
|
||||
);
|
||||
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
|
||||
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
|
||||
velocity.x = 0.0;
|
||||
}
|
||||
if moved.y != wanted.y {
|
||||
y = 0.0;
|
||||
velocity.y = 0.0;
|
||||
}
|
||||
if (wanted.z - moved.z).abs() >= f64::from(1.0e-5_f32) {
|
||||
velocity.z = 0.0;
|
||||
}
|
||||
}
|
||||
let mut velocity = Vec3::new(0.0, y, 0.0);
|
||||
push(self.bounds(), &mut velocity);
|
||||
self.velocity = if in_water {
|
||||
// travelInWater: slowDown 0.8, then `getFluidFallingAdjustedMovement`
|
||||
// with gravity / 16 (the stand is falling while its fall is not
|
||||
// already nearly stopped).
|
||||
let y = velocity.y * f64::from(0.8f32);
|
||||
let slow = f64::from(0.8f32);
|
||||
let y = velocity.y * slow;
|
||||
let adjusted =
|
||||
if falling && (y - 0.005).abs() >= 0.003 && (y - 0.08 / 16.0).abs() < 0.003 {
|
||||
-0.003
|
||||
} else {
|
||||
y - 0.08 / 16.0
|
||||
};
|
||||
Vec3::new(0.0, adjusted, 0.0)
|
||||
Vec3::new(velocity.x * slow, adjusted, velocity.z * slow)
|
||||
} else {
|
||||
Vec3::new(0.0, (velocity.y - 0.08) * f64::from(0.98f32), 0.0)
|
||||
let drag = f64::from(friction * 0.91f32);
|
||||
Vec3::new(
|
||||
velocity.x * drag,
|
||||
(velocity.y - 0.08) * f64::from(0.98f32),
|
||||
velocity.z * drag,
|
||||
)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Dropped-item immersion and currents through the shared vanilla fluid helpers.
|
||||
use super::ItemEntity;
|
||||
//! Dropped-item and armor-stand immersion and currents through the shared vanilla fluid helpers.
|
||||
use super::{ArmorStand, ItemEntity};
|
||||
use crate::{
|
||||
motion::{FluidResources, FluidTags, LAVA, WATER},
|
||||
world::World,
|
||||
@@ -18,66 +18,111 @@ use horizon_world::{
|
||||
registry::BlockStateId,
|
||||
};
|
||||
type Error = std::convert::Infallible;
|
||||
struct ItemFluidWorld<'a> {
|
||||
item: &'a mut ItemEntity,
|
||||
struct EntityFluidWorld<'a> {
|
||||
bounds: Aabb,
|
||||
position: Vec3,
|
||||
eye_y: f64,
|
||||
velocity: &'a mut Vec3,
|
||||
world: &'a World,
|
||||
fluids: &'a FluidResources,
|
||||
}
|
||||
fn update_entity_fluids(
|
||||
bounds: Aabb,
|
||||
position: Vec3,
|
||||
eye_y: f64,
|
||||
velocity: &mut Vec3,
|
||||
world: &World,
|
||||
fluids: &FluidResources,
|
||||
) -> (f64, f64) {
|
||||
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
|
||||
let mut scene = EntityFluidWorld {
|
||||
bounds,
|
||||
position,
|
||||
eye_y,
|
||||
velocity,
|
||||
world,
|
||||
fluids,
|
||||
};
|
||||
if interaction.update(&mut scene, false, 4096).is_err() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let water_height = interaction.fluid_height(&scene, WATER);
|
||||
let lava_height = interaction.fluid_height(&scene, LAVA);
|
||||
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
|
||||
let _ = interaction.apply_current(
|
||||
&mut scene,
|
||||
LAVA,
|
||||
if world.fast_lava {
|
||||
0.007
|
||||
} else {
|
||||
0.0023333333333333335
|
||||
},
|
||||
);
|
||||
(water_height, lava_height)
|
||||
}
|
||||
impl ItemEntity {
|
||||
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
|
||||
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
|
||||
let mut scene = ItemFluidWorld {
|
||||
item: self,
|
||||
update_entity_fluids(
|
||||
self.bounds(),
|
||||
self.position,
|
||||
self.position.y + f64::from(0.2125f32),
|
||||
&mut self.velocity,
|
||||
world,
|
||||
fluids,
|
||||
};
|
||||
if interaction.update(&mut scene, false, 4096).is_err() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let water_height = interaction.fluid_height(&scene, WATER);
|
||||
let lava_height = interaction.fluid_height(&scene, LAVA);
|
||||
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
|
||||
let _ = interaction.apply_current(
|
||||
&mut scene,
|
||||
LAVA,
|
||||
if world.fast_lava {
|
||||
0.007
|
||||
} else {
|
||||
0.0023333333333333335
|
||||
},
|
||||
);
|
||||
(water_height, lava_height)
|
||||
)
|
||||
}
|
||||
}
|
||||
impl FluidMembership for ItemFluidWorld<'_> {
|
||||
impl ArmorStand {
|
||||
/// `Entity.updateFluidHeightAndDoFluidPushing`: refreshes water and lava
|
||||
/// immersion over the stand's deflated box and pushes its velocity by any
|
||||
/// water or lava current. Returns whether the stand is in water (`isInWater`).
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
|
||||
if self.marker {
|
||||
return false;
|
||||
}
|
||||
let eye = if self.small {
|
||||
f64::from(0.9875f32 * 0.9)
|
||||
} else {
|
||||
f64::from(1.7775f32)
|
||||
};
|
||||
let (water_height, _) = update_entity_fluids(
|
||||
self.bounds(),
|
||||
self.position,
|
||||
self.position.y + eye,
|
||||
&mut self.velocity,
|
||||
world,
|
||||
fluids,
|
||||
);
|
||||
water_height > 0.0
|
||||
}
|
||||
}
|
||||
impl FluidMembership for EntityFluidWorld<'_> {
|
||||
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
|
||||
FluidTags.fluid_in_tag(kind, tag)
|
||||
}
|
||||
}
|
||||
impl CurrentTarget for ItemFluidWorld<'_> {
|
||||
impl CurrentTarget for EntityFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn is_player_class(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn velocity(&mut self) -> Result<Vec3, Error> {
|
||||
Ok(self.item.velocity)
|
||||
Ok(*self.velocity)
|
||||
}
|
||||
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> {
|
||||
let v = self.item.velocity;
|
||||
let v = *self.velocity;
|
||||
let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z);
|
||||
if delta.is_finite() && sum.is_finite() {
|
||||
self.item.velocity = sum;
|
||||
*self.velocity = sum;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
impl FluidInteractionWorld for EntityFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Chunk = ();
|
||||
fn interaction_box(&mut self) -> Result<Option<Aabb>, Error> {
|
||||
Ok(Some(
|
||||
self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
|
||||
))
|
||||
Ok(Some(self.bounds.deflate(Vec3::new(0.001, 0.001, 0.001))))
|
||||
}
|
||||
fn loaded_chunk(&mut self, x: i32, z: i32) -> Result<Option<()>, Error> {
|
||||
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
|
||||
@@ -90,16 +135,16 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
})
|
||||
}
|
||||
fn bounding_box_min_y(&self) -> f64 {
|
||||
self.item.position.y
|
||||
self.position.y
|
||||
}
|
||||
fn block_x(&self) -> i32 {
|
||||
self.item.position.x.floor() as i32
|
||||
self.position.x.floor() as i32
|
||||
}
|
||||
fn eye_y(&mut self) -> Result<f64, Error> {
|
||||
Ok(self.item.position.y + f64::from(0.2125f32))
|
||||
Ok(self.eye_y)
|
||||
}
|
||||
fn block_z(&self) -> i32 {
|
||||
self.item.position.z.floor() as i32
|
||||
self.position.z.floor() as i32
|
||||
}
|
||||
fn fluid_state(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
|
||||
@@ -114,7 +159,7 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
|
||||
flow_at(self, f, p)
|
||||
}
|
||||
}
|
||||
impl FluidHeightWorld for ItemFluidWorld<'_> {
|
||||
impl FluidHeightWorld for EntityFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
fn fluid(&mut self, p: [i32; 3]) -> Result<FluidState, Error> {
|
||||
Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2])))
|
||||
@@ -124,7 +169,7 @@ impl FluidHeightWorld for ItemFluidWorld<'_> {
|
||||
Ok(self.fluids.sturdy(state, Direction::Down))
|
||||
}
|
||||
}
|
||||
impl FluidFlowWorld for ItemFluidWorld<'_> {
|
||||
impl FluidFlowWorld for EntityFluidWorld<'_> {
|
||||
type Error = Error;
|
||||
type Block = BlockStateId;
|
||||
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Error> {
|
||||
@@ -214,4 +259,80 @@ mod tests {
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stands_follow_flowing_water_but_markers_and_still_water_stay_in_place() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let mut state = assets.registry.state(water).unwrap().clone();
|
||||
state.properties.insert("level".into(), "1".into());
|
||||
let flowing = assets.registry.resolve(&state).unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let stand = ArmorStand::new(1, at, 0.0);
|
||||
let mut still = stand.clone();
|
||||
let in_water = still.update_fluids(&world, &fluids);
|
||||
assert!(in_water);
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
still.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut marker = stand.clone();
|
||||
marker.marker = true;
|
||||
let in_water = marker.update_fluids(&world, &fluids);
|
||||
assert!(!in_water);
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
marker.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
assert_eq!(marker.position, at);
|
||||
assert_eq!(marker.velocity, Vec3::ZERO);
|
||||
|
||||
let mut carried = stand;
|
||||
let in_water = carried.update_fluids(&world, &fluids);
|
||||
assert!(in_water);
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
assert!((carried.position.x - (at.x + 0.014)).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - 0.014 * f64::from(0.8f32)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
let in_water = carried.update_fluids(&world, &fluids);
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,11 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
use horizon_world::{
|
||||
chunk::BlockState,
|
||||
nbt::{Compound, Tag},
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
@@ -272,7 +276,58 @@ fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile>
|
||||
slim: text("model").map(|model| model == "slim"),
|
||||
})
|
||||
}
|
||||
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
/// `BlockState.CODEC`: the block's name when the state is that block's
|
||||
/// default, `{id, properties}` for any other state.
|
||||
pub fn state_to_nbt(registry: &BlockRegistry, state: BlockStateId) -> Tag {
|
||||
match registry.state(state) {
|
||||
Some(s) if registry.default_state(&s.name) == Some(state) => string(&s.name),
|
||||
Some(s) => s.to_nbt(),
|
||||
None => string("minecraft:air"),
|
||||
}
|
||||
}
|
||||
/// `BlockState.CODEC` read back: a block name or `{id, properties}` (or the
|
||||
/// legacy `{Name, Properties}`). A name the registry knows only as a block
|
||||
/// reads as its default state, and one it does not know at all as `None`.
|
||||
pub fn state_from_nbt(registry: &BlockRegistry, tag: &Tag) -> Option<BlockStateId> {
|
||||
let state = match tag.get("Name") {
|
||||
Some(Tag::String(name)) => BlockState {
|
||||
name: name.to_string_lossy(),
|
||||
properties: match tag.get("Properties") {
|
||||
Some(Tag::Compound(p)) => p
|
||||
.iter()
|
||||
.filter_map(|(k, v)| match v {
|
||||
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Default::default(),
|
||||
},
|
||||
},
|
||||
_ => BlockState::from_nbt(tag).ok()?,
|
||||
};
|
||||
registry
|
||||
.id(&state)
|
||||
.or_else(|| registry.default_state(&state.name))
|
||||
}
|
||||
/// `BlockState.isAir`: air, cave air and void air.
|
||||
fn is_air(registry: &BlockRegistry, state: BlockStateId) -> bool {
|
||||
registry.state(state).is_some_and(|s| {
|
||||
matches!(
|
||||
s.name.as_str(),
|
||||
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
|
||||
)
|
||||
})
|
||||
}
|
||||
/// The mob's entity compound. `registry` is what writes a field holding a block
|
||||
/// state (an enderman's `carriedBlockState`): without one, a caller that has no
|
||||
/// resources (unit tests, structure templates) keeps whatever the loaded
|
||||
/// compound had for it.
|
||||
pub fn mob_to_nbt(
|
||||
m: &Mob,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
registry: Option<&BlockRegistry>,
|
||||
) -> Tag {
|
||||
let base = Base {
|
||||
id: &m.kind.id,
|
||||
uuid,
|
||||
@@ -363,11 +418,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
.iter()
|
||||
.map(|&(effect, duration, amplifier)| {
|
||||
let shown = !m.extra.hidden_effects.contains(&effect);
|
||||
let ambient = m.extra.ambient_effects.contains(&effect);
|
||||
let mut e = vec![
|
||||
("id", string(crate::brewing::namespaced(effect))),
|
||||
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
|
||||
("duration", Tag::Int(duration)),
|
||||
];
|
||||
if ambient {
|
||||
e.push(("ambient", Tag::Byte(1)));
|
||||
}
|
||||
if !shown {
|
||||
e.push(("show_particles", Tag::Byte(0)));
|
||||
}
|
||||
@@ -464,11 +523,27 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
);
|
||||
}
|
||||
}
|
||||
// MushroomCow.addAdditionalSaveData: the Variant codec's name (stew
|
||||
// effects are not modelled).
|
||||
// MushroomCow.addAdditionalSaveData: the Variant codec's name and the
|
||||
// stew effects, written only while it holds any (storeNullable).
|
||||
"minecraft:mooshroom" => {
|
||||
let kind = if m.extra.variant == 1 { "brown" } else { "red" };
|
||||
map.insert("Type".into(), string(kind));
|
||||
match &m.extra.stew_effects {
|
||||
Some(effects) => {
|
||||
let list = effects.iter().map(|(id, duration)| {
|
||||
let mut entry = vec![("id", string(id))];
|
||||
// SuspiciousStewEffects.Entry leaves out the default.
|
||||
if *duration != 160 {
|
||||
entry.push(("duration", Tag::Int(*duration)));
|
||||
}
|
||||
Tag::compound(entry)
|
||||
});
|
||||
map.insert("stew_effects".into(), Tag::List(list.collect()));
|
||||
}
|
||||
None => {
|
||||
map.remove(&horizon_world::nbt::JavaString::from("stew_effects"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
|
||||
"minecraft:rabbit" => {
|
||||
@@ -698,6 +773,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
|
||||
map.insert("type".into(), string(size));
|
||||
}
|
||||
// Pufferfish.addAdditionalSaveData: the puff state (0 small, 1 mid,
|
||||
// 2 full).
|
||||
"minecraft:pufferfish" => {
|
||||
map.insert("PuffState".into(), Tag::Int(m.extra.size));
|
||||
}
|
||||
// TropicalFish.addAdditionalSaveData: the packed variant.
|
||||
"minecraft:tropical_fish" => {
|
||||
let pattern = m.extra.variant.min(11);
|
||||
@@ -821,6 +901,20 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
"minecraft:endermite" => {
|
||||
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
|
||||
}
|
||||
// Enderman.addAdditionalSaveData: the block it carries, saved only
|
||||
// while it carries one.
|
||||
"minecraft:enderman" => match (m.extra.carried, registry) {
|
||||
(Some(state), Some(registry)) => {
|
||||
map.insert("carriedBlockState".into(), state_to_nbt(registry, state));
|
||||
}
|
||||
// Nothing carries a block state out without the registry, and a
|
||||
// caller without one keeps what the loaded compound had, so the
|
||||
// key is only dropped when a registry could have written it.
|
||||
(Some(_), None) | (None, None) => {}
|
||||
(None, Some(_)) => {
|
||||
map.remove(&"carriedBlockState".into());
|
||||
}
|
||||
},
|
||||
"minecraft:shulker" => {
|
||||
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
|
||||
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
|
||||
@@ -1171,7 +1265,15 @@ pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
|
||||
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
|
||||
float(bonus.get("amount"))
|
||||
}
|
||||
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
|
||||
/// The mob a saved compound describes. `registry` is what reads a field holding
|
||||
/// a block state (an enderman's `carriedBlockState`); without one, such a field
|
||||
/// is left out, as it was before it was modelled.
|
||||
pub fn mob_from_nbt(
|
||||
tag: &Tag,
|
||||
types: &HashMap<String, Arc<MobType>>,
|
||||
id: u32,
|
||||
registry: Option<&BlockRegistry>,
|
||||
) -> Option<Mob> {
|
||||
let kind = types.get(&self::kind(tag)?)?.clone();
|
||||
let position = position(tag)?;
|
||||
let [yaw, pitch] = rotation(tag);
|
||||
@@ -1316,6 +1418,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
if number(e.get("show_particles")) == Some(0) {
|
||||
m.extra.hidden_effects.push(effect);
|
||||
}
|
||||
// Details.CODEC: `ambient` defaults to false.
|
||||
m.extra.ambient_effects.retain(|h| *h != effect);
|
||||
if number(e.get("ambient")).is_some_and(|a| a != 0) {
|
||||
m.extra.ambient_effects.push(effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
m.silent = flag(tag, "Silent");
|
||||
@@ -1429,9 +1536,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
}
|
||||
}
|
||||
// MushroomCow.readAdditionalSaveData: a missing or unknown name is
|
||||
// Variant.DEFAULT, red.
|
||||
// Variant.DEFAULT, red; stew_effects decodes like the component's list.
|
||||
crate::mobs::Species::Mooshroom => {
|
||||
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("brown"));
|
||||
if let Some(Tag::List(list)) = tag.get("stew_effects") {
|
||||
m.extra.stew_effects = list
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
Some((
|
||||
Arc::from(text(entry.get("id"))?.as_str()),
|
||||
number(entry.get("duration")).unwrap_or(160) as i32,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
crate::mobs::Species::Rabbit => {
|
||||
// Rabbit.Variant.byId: an unknown id is brown.
|
||||
@@ -1655,6 +1773,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
|
||||
if m.species() == crate::mobs::Species::IronGolem {
|
||||
m.extra.player_created = flag(tag, "PlayerCreated");
|
||||
}
|
||||
// Pufferfish.readAdditionalSaveData: `getIntOr("PuffState", 0)`, at most 2.
|
||||
if m.species() == crate::mobs::Species::Pufferfish {
|
||||
m.extra.size = number(tag.get("PuffState")).unwrap_or(0).min(2) as i32;
|
||||
}
|
||||
// Enderman.readAdditionalSaveData: the block it carried; an air state
|
||||
// reads as no block at all.
|
||||
if m.species() == crate::mobs::Species::Enderman
|
||||
&& let Some(registry) = registry
|
||||
{
|
||||
m.extra.carried = tag
|
||||
.get("carriedBlockState")
|
||||
.and_then(|t| state_from_nbt(registry, t))
|
||||
.filter(|&state| !is_air(registry, state));
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
|
||||
@@ -3120,7 +3252,7 @@ mod axolotl_nbt_tests {
|
||||
Tag::compound([("test:unrelated", Tag::compound([("value", Tag::Int(9))]))]),
|
||||
)]),
|
||||
)]);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
|
||||
let memories = tag.get("Brain").unwrap().get("memories").unwrap();
|
||||
assert!(memories.get("test:unrelated").is_some());
|
||||
assert_eq!(
|
||||
@@ -3238,7 +3370,7 @@ mod axolotl_nbt_tests {
|
||||
|
||||
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 0);
|
||||
assert!(bucket_data(&m).get("HuntingCooldown").is_none());
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag));
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag), None);
|
||||
assert!(
|
||||
saved
|
||||
.get("Brain")
|
||||
@@ -3276,7 +3408,7 @@ mod sheep_nbt_tests {
|
||||
// Summoned white, then dyed red and sheared: the save has it now.
|
||||
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
|
||||
(m.extra.color, m.extra.sheared) = (14, true);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
|
||||
assert_eq!(
|
||||
(number(tag.get("Color")), number(tag.get("Sheared"))),
|
||||
(Some(14), Some(1))
|
||||
@@ -3286,7 +3418,7 @@ mod sheep_nbt_tests {
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
|
||||
@@ -3312,12 +3444,12 @@ mod sheep_nbt_tests {
|
||||
)]);
|
||||
let mut m = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
m.add_effect(crate::brewing::effect("testfx:glow").unwrap().id, 200, 2);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
let Some(Tag::List(saved)) = tag.get("active_effects") else {
|
||||
panic!("no active_effects");
|
||||
};
|
||||
assert_eq!(text(saved[0].get("id")).as_deref(), Some("testfx:glow"));
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.effects, [("testfx:glow", 200, 2)]);
|
||||
}
|
||||
|
||||
@@ -3341,7 +3473,7 @@ mod sheep_nbt_tests {
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(
|
||||
text(tag.get("variant")).as_deref(),
|
||||
Some("minecraft:temperate")
|
||||
@@ -3352,13 +3484,13 @@ mod sheep_nbt_tests {
|
||||
);
|
||||
// Spawned in a warm biome with the mini sounds.
|
||||
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
|
||||
assert_eq!(
|
||||
text(tag.get("sound_variant")).as_deref(),
|
||||
Some("minecraft:mini")
|
||||
);
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
|
||||
let mut hen = Mob::new(
|
||||
3,
|
||||
@@ -3367,9 +3499,9 @@ mod sheep_nbt_tests {
|
||||
0.0,
|
||||
);
|
||||
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
|
||||
let back = mob_from_nbt(&tag, &types, 4).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 4, None).unwrap();
|
||||
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
|
||||
// The sound variants are indices into the registries finalizeSpawn draws from.
|
||||
use crate::mobs::{Species, finalize};
|
||||
@@ -3408,16 +3540,16 @@ mod sheep_nbt_tests {
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Age", Tag::Int(-21359)),
|
||||
]);
|
||||
let m = mob_from_nbt(&saved, &types, 1).unwrap();
|
||||
let m = mob_from_nbt(&saved, &types, 1, None).unwrap();
|
||||
assert_eq!(m.extra.age, -21359);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("Age")), Some(-21359));
|
||||
assert_eq!(number(tag.get("AgeLocked")), Some(0));
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
|
||||
fn mobs_keep_their_variant_puff_state_and_carried_block() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
@@ -3430,11 +3562,18 @@ mod sheep_nbt_tests {
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let registry = horizon_world::registry::BlockRegistry::from_facts_json(
|
||||
std::fs::File::open(root.join("facts.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
let round_trip = |m: &Mob| {
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
|
||||
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
|
||||
let tag = mob_to_nbt(m, [1, 2, 3, 4], None, Some(®istry));
|
||||
(mob_from_nbt(&tag, &types, 2, Some(®istry)).unwrap(), tag)
|
||||
};
|
||||
// A save with nothing but where the mob stands.
|
||||
let empty =
|
||||
|kind: &str| Tag::compound([("id", string(kind)), ("Pos", doubles(&[0.5, 64.0, 0.5]))]);
|
||||
let mut fox = new("minecraft:fox");
|
||||
fox.extra.variant = 1;
|
||||
let (back, tag) = round_trip(&fox);
|
||||
@@ -3452,6 +3591,24 @@ mod sheep_nbt_tests {
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(text(tag.get("Type")).as_deref(), Some("red"));
|
||||
assert_eq!(back.extra.variant, 0);
|
||||
// Its stew effects round-trip as the component's bare {id, duration}
|
||||
// entries, and a cleared list is not written at all.
|
||||
mooshroom.extra.variant = 1;
|
||||
mooshroom.extra.stew_effects = Some(vec![(Arc::from("minecraft:night_vision"), 100)]);
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(back.extra.stew_effects, mooshroom.extra.stew_effects);
|
||||
let entry = match tag.get("stew_effects") {
|
||||
Some(Tag::List(list)) => list.first().expect("one effect"),
|
||||
other => panic!("stew_effects: {other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
(text(entry.get("id")), number(entry.get("duration"))),
|
||||
(Some("minecraft:night_vision".into()), Some(100))
|
||||
);
|
||||
mooshroom.extra.stew_effects = None;
|
||||
let (back, tag) = round_trip(&mooshroom);
|
||||
assert_eq!(back.extra.stew_effects, None);
|
||||
assert!(tag.get("stew_effects").is_none());
|
||||
let mut rabbit = new("minecraft:rabbit");
|
||||
rabbit.extra.variant = 4;
|
||||
let (back, tag) = round_trip(&rabbit);
|
||||
@@ -3465,13 +3622,80 @@ mod sheep_nbt_tests {
|
||||
(back.extra.screaming, back.extra.lost_horns),
|
||||
(true, [false, true])
|
||||
);
|
||||
// A goat saved without horn flags has both, as vanilla reads it.
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:goat")),
|
||||
// Pufferfish.addAdditionalSaveData: the puff state, 0 when the save
|
||||
// has none and at most 2 when it has more.
|
||||
let mut pufferfish = new("minecraft:pufferfish");
|
||||
pufferfish.extra.size = 2;
|
||||
let (back, tag) = round_trip(&pufferfish);
|
||||
assert_eq!(number(tag.get("PuffState")), Some(2));
|
||||
assert_eq!(back.extra.size, 2);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&empty("minecraft:pufferfish"), &types, 3, Some(®istry))
|
||||
.unwrap()
|
||||
.extra
|
||||
.size,
|
||||
0
|
||||
);
|
||||
let puffed = Tag::compound([
|
||||
("id", string("minecraft:pufferfish")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("PuffState", Tag::Int(5)),
|
||||
]);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
|
||||
mob_from_nbt(&puffed, &types, 3, Some(®istry))
|
||||
.unwrap()
|
||||
.extra
|
||||
.size,
|
||||
2
|
||||
);
|
||||
// Enderman.addAdditionalSaveData: the block it carries, a block name
|
||||
// for the block's default state and `{id, properties}` otherwise.
|
||||
let mut enderman = new("minecraft:enderman");
|
||||
let grass = registry.default_state("minecraft:grass_block");
|
||||
enderman.extra.carried = grass;
|
||||
let (back, tag) = round_trip(&enderman);
|
||||
assert_eq!(
|
||||
text(tag.get("carriedBlockState")).as_deref(),
|
||||
Some("minecraft:grass_block")
|
||||
);
|
||||
assert_eq!(back.extra.carried, grass);
|
||||
let log = registry
|
||||
.id(&horizon_world::chunk::BlockState {
|
||||
name: "minecraft:oak_log".into(),
|
||||
properties: [("axis".to_owned(), "x".to_owned())].into(),
|
||||
})
|
||||
.unwrap();
|
||||
enderman.extra.carried = Some(log);
|
||||
let (back, tag) = round_trip(&enderman);
|
||||
assert!(matches!(
|
||||
tag.get("carriedBlockState"),
|
||||
Some(Tag::Compound(_))
|
||||
));
|
||||
assert_eq!(back.extra.carried, Some(log));
|
||||
// Nothing carried: no key, and none read back; an air state reads as
|
||||
// no block at all.
|
||||
enderman.extra.carried = None;
|
||||
let (back, tag) = round_trip(&enderman);
|
||||
assert_eq!(tag.get("carriedBlockState"), None);
|
||||
assert_eq!(back.extra.carried, None);
|
||||
let air = Tag::compound([
|
||||
("id", string("minecraft:enderman")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("carriedBlockState", string("minecraft:air")),
|
||||
]);
|
||||
assert_eq!(
|
||||
mob_from_nbt(&air, &types, 3, Some(®istry))
|
||||
.unwrap()
|
||||
.extra
|
||||
.carried,
|
||||
None
|
||||
);
|
||||
// A goat saved without horn flags has both, as vanilla reads it.
|
||||
assert_eq!(
|
||||
mob_from_nbt(&empty("minecraft:goat"), &types, 3, None)
|
||||
.unwrap()
|
||||
.extra
|
||||
.lost_horns,
|
||||
[false; 2]
|
||||
);
|
||||
}
|
||||
@@ -3515,6 +3739,60 @@ mod item_component_tests {
|
||||
apply_item_components(&mut sheep, &egg);
|
||||
assert_eq!(sheep.extra.color, 5, "lime, not the white it started as");
|
||||
}
|
||||
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
|
||||
/// spawned, when the type is its own — the mob's compound, the data merged
|
||||
/// over it and loaded back, as the spawn egg site does it.
|
||||
#[test]
|
||||
fn a_spawn_eggs_entity_data_reaches_the_mob_it_spawned() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let egg = |entity_data: Option<Tag>| {
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:zombie_spawn_egg", 1);
|
||||
if let Some(data) = entity_data {
|
||||
egg.data_mut()
|
||||
.set_other("minecraft:entity_data", Some(data));
|
||||
}
|
||||
egg
|
||||
};
|
||||
let spawn = |stack: &crate::items::ItemStack| {
|
||||
let mut m = Mob::new(
|
||||
2,
|
||||
types["minecraft:zombie"].clone(),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
);
|
||||
apply_item_components(&mut m, stack);
|
||||
let Some(data) =
|
||||
crate::block_entity_item::spawn_egg_entity_data(stack, &m.kind.id, false)
|
||||
else {
|
||||
return m;
|
||||
};
|
||||
let tag = crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None), data);
|
||||
mob_from_nbt(&tag, &types, 2, None).unwrap()
|
||||
};
|
||||
// `entity_data={NoAI:1b}`: no id, so the egg's own type holds the data.
|
||||
assert!(spawn(&egg(Some(Tag::compound([("NoAI", Tag::Byte(1))])))).no_ai);
|
||||
// `entity_data.id` names another type: the egg's own type, data skipped.
|
||||
let other = spawn(&egg(Some(Tag::compound([
|
||||
("id", string("minecraft:pig")),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
]))));
|
||||
assert_eq!(other.kind.id.as_str(), "minecraft:zombie");
|
||||
assert!(!other.no_ai);
|
||||
// A plain egg is unchanged.
|
||||
assert!(!spawn(&egg(None)).no_ai);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -3599,15 +3877,15 @@ mod neutral_anger_tests {
|
||||
m.extra.anger = 400;
|
||||
m.extra.anger_end = 3000;
|
||||
m.extra.angry_at = Some(crate::mobs::Who::Player);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
|
||||
assert!(tag.get("angry_at").is_some());
|
||||
let back = mob_from_nbt(&tag, &types, 2).unwrap();
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.anger_end, 3000);
|
||||
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
|
||||
// A mob that is no NeutralMob writes no anger.
|
||||
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
|
||||
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None);
|
||||
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None);
|
||||
assert!(tag.get("anger_end_time").is_none());
|
||||
// The anger that is left when the mob comes back.
|
||||
assert_eq!(crate::mobs::anger_left(3000, 1000), 2000);
|
||||
@@ -3637,8 +3915,8 @@ mod held_item_name_tests {
|
||||
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
|
||||
sword.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Old Sword".into()));
|
||||
mob.extra.set_main_hand_stack(Some(sword));
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None);
|
||||
let mut loaded = mob_from_nbt(&raw, &types, 2).unwrap();
|
||||
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
|
||||
let mut loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
loaded
|
||||
.extra
|
||||
@@ -3651,8 +3929,8 @@ mod held_item_name_tests {
|
||||
loaded
|
||||
.extra
|
||||
.set_main_hand(Some("minecraft:diamond_sword".into()));
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw));
|
||||
let restored = mob_from_nbt(&saved, &types, 3).unwrap();
|
||||
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw), None);
|
||||
let restored = mob_from_nbt(&saved, &types, 3, None).unwrap();
|
||||
assert_eq!(
|
||||
restored.extra.main_hand.as_deref(),
|
||||
Some("minecraft:diamond_sword")
|
||||
|
||||
@@ -547,6 +547,17 @@ impl HandSway {
|
||||
let y_bob = crate::camera::lerp(partial, self.y_bob_o, self.y_bob);
|
||||
[(view_x_rot - x_bob) * 0.1, (view_y_rot - y_bob) * 0.1]
|
||||
}
|
||||
/// The hands' view space to camera-relative world space, turned by the
|
||||
/// sway. The hand pass draws with the camera's view, whose rotation a
|
||||
/// ridden boat's turn spreads over the frames of a tick
|
||||
/// (`LocalPlayer.getViewYRot`): anything else swings the hands back and
|
||||
/// forth by that turn, up to 10 degrees, 20 times a second (#4583).
|
||||
pub fn to_world(&self, partial: f32, camera: &crate::camera::Camera) -> glam::Mat4 {
|
||||
let [x, y] = self.rotation(partial, camera.x_rot, camera.y_rot);
|
||||
crate::camera::view_matrix(camera.y_rot, camera.x_rot).inverse()
|
||||
* glam::Mat4::from_rotation_x(x.to_radians())
|
||||
* glam::Mat4::from_rotation_y(y.to_radians())
|
||||
}
|
||||
}
|
||||
|
||||
/// `HitResult` at the crosshair (`Minecraft.hitResult`).
|
||||
@@ -966,6 +977,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #4583: while a boat turns the player steadily, the hands keep their
|
||||
/// place on screen through every frame of a tick.
|
||||
#[test]
|
||||
fn the_hands_hold_still_on_screen_while_a_boat_turns() {
|
||||
const TURN: f32 = 10.0;
|
||||
let mut sway = HandSway::default();
|
||||
let mut yaw = 0.0;
|
||||
for _ in 0..30 {
|
||||
sway.tick(0.0, yaw);
|
||||
yaw += TURN;
|
||||
}
|
||||
let on_screen = |partial: f32| {
|
||||
let camera = crate::camera::Camera {
|
||||
y_rot: yaw - TURN * (1.0 - partial),
|
||||
..Default::default()
|
||||
};
|
||||
crate::camera::view_matrix(camera.y_rot, 0.0) * sway.to_world(partial, &camera)
|
||||
};
|
||||
let first = on_screen(0.0);
|
||||
for partial in [0.25, 0.5, 0.75, 0.99] {
|
||||
let frame = on_screen(partial);
|
||||
assert!(frame.abs_diff_eq(first, 1.0e-3), "{partial}: {frame:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() {
|
||||
let (bow, apple) = (
|
||||
|
||||
@@ -87,6 +87,7 @@ mod creatures;
|
||||
mod crossbow;
|
||||
pub(crate) mod cushions;
|
||||
mod debug_keys;
|
||||
mod debug_options;
|
||||
mod debug_stick;
|
||||
mod decorated_pot;
|
||||
mod dedicated;
|
||||
@@ -160,6 +161,7 @@ mod maps;
|
||||
mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mob_looting;
|
||||
mod mob_sounds;
|
||||
mod mods;
|
||||
mod mount_menu;
|
||||
@@ -706,6 +708,8 @@ enum Screen {
|
||||
DialogWarning,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// DebugOptionsScreen, F3+F6 (game/debug_options.rs).
|
||||
DebugOptions,
|
||||
}
|
||||
struct Mining {
|
||||
pos: [i32; 3],
|
||||
@@ -2387,8 +2391,11 @@ struct Game<'a> {
|
||||
debug_keys: horizon_client::debug_keys::DebugKeys,
|
||||
/// The F3 overlay's charts (F3+2, F3+3).
|
||||
debug_charts: horizon_client::debug_chart::DebugCharts,
|
||||
show_hitboxes: bool,
|
||||
show_chunk_borders: bool,
|
||||
/// `Minecraft.debugEntries`: what the overlay and the debug renderers
|
||||
/// show (`debug-profile.json`, F3+F6).
|
||||
debug_entries: horizon_client::debug_entries::DebugEntries,
|
||||
/// `DebugOptionsScreen` while it is open (game/debug_options.rs).
|
||||
debug_options: Option<debug_options::DebugOptionsUi>,
|
||||
/// PauseScreen.showPauseMenu (false after F3+Escape).
|
||||
pause_menu: bool,
|
||||
/// MultiPlayerGameMode.previousLocalPlayerMode (F3+N goes back to it).
|
||||
@@ -2412,6 +2419,9 @@ struct Game<'a> {
|
||||
tick_hit: Option<Option<Hit>>,
|
||||
/// The part of a right click a guest's packet asks for (game/lan_actions.rs).
|
||||
guest_use: Option<GuestUse>,
|
||||
/// The last `use_hand` that ended the click did so with `FAIL`, which
|
||||
/// does not consume the action (handleUseItemOn's ANY_BLOCK_USE).
|
||||
use_failed: bool,
|
||||
/// Level events of this tick for the guests, and the guest acting now.
|
||||
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
|
||||
/// Jukebox level events 1010 (song name) and 1011 (`None`) for the guests;
|
||||
@@ -2841,8 +2851,8 @@ impl Game<'_> {
|
||||
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
|
||||
/// (decreaseAirSupply), the wings, leather boots on powder snow
|
||||
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
|
||||
/// (LivingEntity.canFreeze), the dimension's FAST_LAVA and the sleeping
|
||||
/// pose.
|
||||
/// (LivingEntity.canFreeze), the dimension's FAST_LAVA, the sleeping
|
||||
/// pose and blindness (Player.isMobilityRestricted, which stops a sprint).
|
||||
pub(crate) fn sync_player_body(&mut self) {
|
||||
let glider = self.glider_worn();
|
||||
let sleeping = self.sleeping.is_some();
|
||||
@@ -2867,10 +2877,12 @@ impl Game<'_> {
|
||||
let freeze_immune = self.wears_freeze_immune();
|
||||
let burn_immune = self.wears_burn_from_stepping_immunity();
|
||||
let nudge = self.closest_space_nudge();
|
||||
let blind = self.potions.effects.get("blindness").is_some();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return;
|
||||
};
|
||||
player.nudge = nudge;
|
||||
player.mobility_restricted = blind;
|
||||
player.yaw = self.yaw;
|
||||
player.pitch = self.pitch;
|
||||
player.auto_jump_enabled = self.settings.auto_jump;
|
||||
@@ -3130,6 +3142,12 @@ impl Game<'_> {
|
||||
self.spawn_item(stack, at, v, 40);
|
||||
}
|
||||
}
|
||||
/// The item entity `spawn_item` just added, unless a native mod kept it
|
||||
/// out of the level (`native_entity_join_level`).
|
||||
pub(super) fn just_spawned_item(&mut self) -> Option<&mut ItemEntity> {
|
||||
let id = self.next_entity;
|
||||
self.dropped.last_mut().filter(|d| d.id == id)
|
||||
}
|
||||
fn spawn_item(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
@@ -3246,9 +3264,12 @@ impl Game<'_> {
|
||||
yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength,
|
||||
];
|
||||
self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40);
|
||||
// A native mod can keep the item out of the level (native_entity_join_level).
|
||||
if thrown_from_hand {
|
||||
let uuid = self.acting_player_uuid();
|
||||
self.dropped.last_mut().unwrap().thrower = Some(uuid);
|
||||
if let Some(item) = self.just_spawned_item() {
|
||||
item.thrower = Some(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The UUID of the player acting: the guest whose turn it is, else the host.
|
||||
@@ -3717,35 +3738,46 @@ impl Game<'_> {
|
||||
self.tick_chunks.recycle_added(added);
|
||||
}
|
||||
/// `DistanceManager`'s tickets of the level in place, from every player in
|
||||
/// it (the local player, LAN guests, a server's players), its forced
|
||||
/// chunks and its portal tickets. A game with neither a server nor a
|
||||
/// player (the headless fixtures) ticks everywhere.
|
||||
/// it (the local player, LAN guests, a server's players; a spectator the
|
||||
/// rule skips holds none), its forced chunks and its portal tickets. A game
|
||||
/// with neither a server nor a player (the headless fixtures) ticks
|
||||
/// everywhere.
|
||||
fn update_chunk_tickets(&mut self) {
|
||||
let mut players: Vec<(i32, i32)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
let rule = self.ui.rules.bool("spectators_generate_chunks");
|
||||
let mut players: Vec<((i32, i32), bool)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| h.hub.here(g))
|
||||
.map(|g| g.chunk())
|
||||
.map(|g| (g.chunk(), g.spectator()))
|
||||
.collect()
|
||||
});
|
||||
if let Some(p) = &self.player {
|
||||
players.push((
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
(
|
||||
(p.position.x.floor() as i32) >> 4,
|
||||
(p.position.z.floor() as i32) >> 4,
|
||||
),
|
||||
self.spectator(),
|
||||
));
|
||||
}
|
||||
let sources =
|
||||
(!self.options.execution_role.is_client() || !players.is_empty()).then(|| {
|
||||
horizon_client::ticket_levels::Sources {
|
||||
players,
|
||||
forced: self.streaming.forced.iter().copied().collect(),
|
||||
// Filled from the `PORTAL` tickets by `update`.
|
||||
portal: Vec::new(),
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.simulation_distance.max(2),
|
||||
}
|
||||
});
|
||||
// A spectator the rule skips is still a player: a game that has one
|
||||
// must not take the headless fixtures' tick-everywhere case below.
|
||||
let any_player = !players.is_empty();
|
||||
let sources = (!self.options.execution_role.is_client() || any_player).then(|| {
|
||||
horizon_client::ticket_levels::Sources {
|
||||
players: players
|
||||
.into_iter()
|
||||
.filter(|&(_, spectator)| game_mode::loads_chunks(rule, spectator))
|
||||
.map(|(chunk, _)| chunk)
|
||||
.collect(),
|
||||
forced: self.streaming.forced.iter().copied().collect(),
|
||||
// Filled from the `PORTAL` tickets by `update`.
|
||||
portal: Vec::new(),
|
||||
view_distance: self.settings.render_distance.clamp(2, 32),
|
||||
simulation_distance: self.settings.simulation_distance.max(2),
|
||||
}
|
||||
});
|
||||
self.tickets.update(sources);
|
||||
}
|
||||
/// `ServerLevel.startTickingChunk`: a loaded chunk's saved ticks are
|
||||
@@ -5084,6 +5116,12 @@ impl Game<'_> {
|
||||
if entity && hand == Hand::Off && self.with_hand(hand, Game::interact_entity)? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
// A client of a server never hits a fence knot with its crosshair:
|
||||
// `LeadItem.useOn` and `LeashFenceKnotEntity.interact` reach the
|
||||
// server as a click on the fence block.
|
||||
if on_block && self.with_hand(hand, Game::interact_knot) {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
if self.with_hand(hand, |game| game.use_hand(block_hit.as_ref()))? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
@@ -5141,6 +5179,7 @@ impl Game<'_> {
|
||||
/// One hand's turn in `startUseItem`: `useItemOn` the block aimed at,
|
||||
/// then `useItem`. Whether the click ends here (it acted or failed).
|
||||
fn use_hand(&mut self, block_hit: Option<&Hit>) -> Result<bool> {
|
||||
self.use_failed = false;
|
||||
// isSecondaryUseActive with something in either hand keeps blocks
|
||||
// from reacting.
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
@@ -5152,6 +5191,8 @@ impl Game<'_> {
|
||||
let mut canceled = false;
|
||||
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
|
||||
if canceled {
|
||||
// Nothing was used, so nothing consumed the action.
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@@ -5267,8 +5308,8 @@ impl Game<'_> {
|
||||
let cake = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
|
||||
let adds_candle =
|
||||
self.with_level(|b, _, _| b.cake_candle_state(cake, item.as_deref()).is_some());
|
||||
let result = self.with_level(|b, level, ticks| {
|
||||
b.use_item_on(
|
||||
let (result, branch) = self.with_level(|b, level, ticks| {
|
||||
b.use_item_on_branch(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
@@ -5286,7 +5327,6 @@ impl Game<'_> {
|
||||
{
|
||||
self.stats.add("used", &used.item, 1);
|
||||
self.consume_held();
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, used);
|
||||
}
|
||||
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
|
||||
if result == horizon_world::level::InteractionResult::Success {
|
||||
@@ -5304,6 +5344,17 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if result.consumes_action() {
|
||||
// ServerPlayerGameMode.useItemOn: ITEM_USED_ON_BLOCK with
|
||||
// the stack before the use, or DEFAULT_BLOCK_USE.
|
||||
match branch {
|
||||
horizon_world::level::UseBranch::ItemOn => {
|
||||
let used = held.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, &used);
|
||||
}
|
||||
horizon_world::level::UseBranch::WithoutItem => {
|
||||
self.trigger_default_block_use(hit.pos);
|
||||
}
|
||||
}
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -5356,6 +5407,9 @@ impl Game<'_> {
|
||||
// `ServerPlayerGameMode.useItemOn`: a consuming `useOn`
|
||||
// triggers ITEM_USED_ON_BLOCK with the stack before the use.
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, &stack);
|
||||
} else {
|
||||
// DebugStickItem.useOn's FAIL.
|
||||
self.use_failed = true;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -5374,7 +5428,10 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
UseResult::Fail => return Ok(true),
|
||||
UseResult::Fail => {
|
||||
self.use_failed = true;
|
||||
return Ok(true);
|
||||
}
|
||||
UseResult::Pass => {}
|
||||
}
|
||||
// PotionItem.useOn comes before PotionItem.use's drink.
|
||||
@@ -5453,6 +5510,7 @@ impl Game<'_> {
|
||||
|| self.place_painting(hit, &stack)
|
||||
|| self.place_item_frame(hit, &stack)
|
||||
|| self.use_lodestone_compass(hit, &stack)
|
||||
|| self.use_map_on_banner(hit, &stack)
|
||||
|| self.place_armor_stand(hit, &stack)
|
||||
|| self.place_cushion(hit, &stack)
|
||||
|| self.place_end_crystal(hit, &stack))
|
||||
@@ -5515,6 +5573,9 @@ impl Game<'_> {
|
||||
// which ItemStack.useOn counts as a use of the item.
|
||||
if self.start_eating(&info) {
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
} else {
|
||||
// BlockItem.place's FAIL.
|
||||
self.use_failed = true;
|
||||
}
|
||||
return Ok(true);
|
||||
};
|
||||
@@ -6803,6 +6864,7 @@ impl Game<'_> {
|
||||
| Screen::ConfirmLink
|
||||
| Screen::DialogWarning
|
||||
| Screen::CommandBlock
|
||||
| Screen::DebugOptions
|
||||
) || self.book.is_some()
|
||||
|| self.win_screen_pauses())
|
||||
}
|
||||
@@ -7159,6 +7221,10 @@ impl Game<'_> {
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
let mut batch = HudBatch::new(size);
|
||||
// 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)
|
||||
/ 24000.0;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
if self.screen == Screen::Win {
|
||||
self.draw_win_screen(&mut batch);
|
||||
@@ -7459,6 +7525,7 @@ impl Game<'_> {
|
||||
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
|
||||
Screen::CommandBlock => self.draw_command_block(&mut batch),
|
||||
Screen::BlockEditor => self.draw_block_editor(&mut batch),
|
||||
Screen::DebugOptions => self.draw_debug_options(&mut batch),
|
||||
Screen::Inventory => {
|
||||
self.draw_inventory(&mut batch);
|
||||
self.draw_mod_browser(&mut batch);
|
||||
@@ -9422,23 +9489,23 @@ impl Game<'_> {
|
||||
p.z + (q.z - p.z) * a,
|
||||
)
|
||||
};
|
||||
let mut casters: Vec<(glam::DVec3, &str, f32)> =
|
||||
let mut casters: Vec<(glam::DVec3, f32, f32)> =
|
||||
horizon_client::shadows::mob_casters(&self.mobs, a).collect();
|
||||
let fixed = |id: &'static str| (id, horizon_client::shadows::shadow_params(id).0);
|
||||
let (item, item_radius) = fixed("minecraft:item");
|
||||
let item = horizon_client::shadows::shadow_params("minecraft:item");
|
||||
casters.extend(
|
||||
self.dropped
|
||||
.iter()
|
||||
.map(|i| (lerp(i.previous, i.position), item, item_radius)),
|
||||
.map(|i| (lerp(i.previous, i.position), item.0, item.1)),
|
||||
);
|
||||
let (orb, orb_radius) = fixed("minecraft:experience_orb");
|
||||
let orb = horizon_client::shadows::shadow_params("minecraft:experience_orb");
|
||||
casters.extend(
|
||||
self.orbs
|
||||
.iter()
|
||||
.map(|o| (lerp(o.previous, o.position), orb, orb_radius)),
|
||||
.map(|o| (lerp(o.previous, o.position), orb.0, orb.1)),
|
||||
);
|
||||
for (pos, id, radius) in casters {
|
||||
let strength = horizon_client::shadows::shadow_params(id).1;
|
||||
// Text, block and item displays (DisplayRenderer's shadow radius).
|
||||
casters.extend(self.text_displays.values().filter_map(|d| d.shadow(a)));
|
||||
for (pos, radius, strength) in casters {
|
||||
horizon_client::shadows::shadow(
|
||||
&self.world,
|
||||
self.res.facts(),
|
||||
@@ -9832,14 +9899,10 @@ impl Game<'_> {
|
||||
target_kind: &str,
|
||||
living: bool,
|
||||
) -> (f32, f32, bool, bool) {
|
||||
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
|
||||
let hand_damage = self.attack_damage();
|
||||
// Riptide's spin replaces the attack damage attribute.
|
||||
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
|
||||
let damage = if spin > 0.0 {
|
||||
spin
|
||||
} else {
|
||||
hand_damage + self.effect_attack_bonus()
|
||||
};
|
||||
let damage = if spin > 0.0 { spin } else { hand_damage };
|
||||
let scale = self.attack_strength_scale();
|
||||
// getEnchantedDamage(target, damage, source) - damage, taken before the
|
||||
// attack strength scales the base damage.
|
||||
@@ -10360,8 +10423,8 @@ impl Game<'_> {
|
||||
creative,
|
||||
options,
|
||||
} => self.open_world(&folder, Some(&name), seed, creative, Some(&options))?,
|
||||
Action::Connect { address } => {
|
||||
self.connect_server(&address, &self.options.player_name())?
|
||||
Action::Connect { address, lan } => {
|
||||
self.connect_server_with_options(&address, &self.options.player_name(), None, lan)?
|
||||
}
|
||||
Action::DataPacks { dir, selected } => {
|
||||
self.keys.clear();
|
||||
@@ -10483,6 +10546,13 @@ impl Game<'_> {
|
||||
self.score = Default::default();
|
||||
self.fns = Default::default();
|
||||
self.world_tables = self.build_world_tables();
|
||||
// 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();
|
||||
}
|
||||
// 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(
|
||||
@@ -12126,12 +12196,8 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
let heights = self.heights.interpolated(a);
|
||||
let view = horizon_client::camera::view_matrix(self.yaw, self.pitch);
|
||||
// The hand sway: both hands turn by a tenth of the bobs' lag.
|
||||
let [sway_x, sway_y] = self.sway.rotation(a, self.pitch, self.yaw);
|
||||
let to_world = view.inverse()
|
||||
* glam::Mat4::from_rotation_x(sway_x.to_radians())
|
||||
* glam::Mat4::from_rotation_y(sway_y.to_radians());
|
||||
let to_world = self.sway.to_world(a, &self.view);
|
||||
let eye = self.eye_position().unwrap_or_default();
|
||||
let light = self.world.light(
|
||||
eye[0].floor() as i32,
|
||||
@@ -13054,6 +13120,17 @@ impl Game<'_> {
|
||||
pub(super) fn sync_level(&mut self) {
|
||||
// Level.write saves the game rules (game_rules.dat) with the rest.
|
||||
self.level.game_rules = self.ui.rules.by_name();
|
||||
// PrimaryLevelData.setDifficulty: /difficulty and the options button change it.
|
||||
self.level.difficulty =
|
||||
horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into();
|
||||
self.level.time = self.world.game_time;
|
||||
self.level.weather = self.world.weather;
|
||||
self.level.day_time = self.world.day_time;
|
||||
// A server has no in-frame player of its own; `enter_guest` puts one in
|
||||
// `self.player` only while a command reads it.
|
||||
if self.player.is_none() && self.options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
self.level.selected_slot = self.selected as i32;
|
||||
let mode = self.mode_id();
|
||||
// The vehicle the player rides (`ServerPlayer.saveParentVehicle`).
|
||||
@@ -13062,12 +13139,6 @@ impl Game<'_> {
|
||||
level.creative = self.creative;
|
||||
level.game_mode = mode;
|
||||
level.previous_game_mode = self.previous_mode.map(game_mode::mode_id_of);
|
||||
// PrimaryLevelData.setDifficulty: /difficulty and the options button change it.
|
||||
level.difficulty =
|
||||
horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into();
|
||||
level.time = self.world.game_time;
|
||||
level.weather = self.world.weather;
|
||||
level.day_time = self.world.day_time;
|
||||
level.spawn = self.spawn.map(|s| [s.x, s.y, s.z]);
|
||||
level.respawn_elsewhere = self.respawn_config.map(|c| c.saved());
|
||||
level.rotation = [self.yaw, self.pitch];
|
||||
@@ -13171,11 +13242,11 @@ impl Game<'_> {
|
||||
self.save_warden_tracker();
|
||||
self.sync_level();
|
||||
self.store_owner();
|
||||
if self.options.dedicated.is_some() {
|
||||
self.level.write_without_player(&self.world_root)?;
|
||||
} else {
|
||||
self.level.write(&self.world_root)?;
|
||||
}
|
||||
write_level_for_role(
|
||||
&mut self.level,
|
||||
&self.world_root,
|
||||
self.options.execution_role,
|
||||
)?;
|
||||
self.save_world_state();
|
||||
self.message = Some(("Saved the game".into(), Instant::now()));
|
||||
Ok(())
|
||||
@@ -13302,10 +13373,16 @@ impl Game<'_> {
|
||||
horizon_client::level_loading::draw_progress(
|
||||
&mut hud,
|
||||
&self.gui.atlas,
|
||||
if loader.cancelled {
|
||||
"Cancelling world"
|
||||
&if loader.cancelled {
|
||||
horizon_client::lang::get_or(
|
||||
"horizon.loading.cancelling_world",
|
||||
"Cancelling world",
|
||||
)
|
||||
} else {
|
||||
"Opening world"
|
||||
horizon_client::lang::get_or(
|
||||
"horizon.loading.opening_world",
|
||||
"Opening world",
|
||||
)
|
||||
},
|
||||
0.0,
|
||||
);
|
||||
@@ -13973,6 +14050,7 @@ impl Game<'_> {
|
||||
Screen::BlockEditor => self.close_block_editor(),
|
||||
Screen::Chat => self.close_chat(chat::ChatExit::Intentional),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -14185,7 +14263,7 @@ impl Game<'_> {
|
||||
Some(renderer) => {
|
||||
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
|
||||
}
|
||||
None => distance.min(1024),
|
||||
None => distance.min(horizon_client::lod::MAX_CHUNKS),
|
||||
}
|
||||
}
|
||||
/// Only enabled LOD worlds extend the renderer's range, never simulation.
|
||||
@@ -14846,6 +14924,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::ServerLinks && d != 0.0 {
|
||||
self.server_links_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::DebugOptions && d != 0.0 {
|
||||
self.debug_options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Advancements {
|
||||
self.advancements_scroll(scaled(raw_x) as f32, d);
|
||||
}
|
||||
@@ -15043,6 +15124,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
Screen::ServerLinks if pressed && button == MouseButton::Left => {
|
||||
self.server_links_click()?
|
||||
}
|
||||
Screen::DebugOptions if pressed && button == MouseButton::Left => {
|
||||
self.debug_options_click()
|
||||
}
|
||||
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
|
||||
self.confirm_link_click()
|
||||
}
|
||||
@@ -15213,6 +15297,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
|
||||
self.command_block_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::DebugOptions => {
|
||||
self.debug_options_key(&event)
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::BlockEditor => {
|
||||
self.block_editor_key(&event)
|
||||
}
|
||||
@@ -15296,7 +15383,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
// Screen.keyPressed sees the physical key, repeats included.
|
||||
if event.state == ElementState::Pressed
|
||||
&& self.screen == Screen::Inventory
|
||||
&& self.merchant_key(key)
|
||||
&& (self.merchant_key(key) || self.recipe_book_key(key))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -16048,8 +16135,10 @@ impl<'a> Game<'a> {
|
||||
friends: None,
|
||||
debug_keys: Default::default(),
|
||||
debug_charts: Default::default(),
|
||||
show_hitboxes: false,
|
||||
show_chunk_borders: false,
|
||||
debug_entries: horizon_client::debug_entries::DebugEntries::load(
|
||||
&std::env::current_dir().unwrap_or_default(),
|
||||
),
|
||||
debug_options: None,
|
||||
pause_menu: true,
|
||||
previous_mode: None,
|
||||
gamemode_switcher: None,
|
||||
@@ -16061,6 +16150,7 @@ impl<'a> Game<'a> {
|
||||
forced_hit: None,
|
||||
tick_hit: None,
|
||||
guest_use: None,
|
||||
use_failed: false,
|
||||
lan_events: Vec::new(),
|
||||
lan_songs: Vec::new(),
|
||||
lan_cushion_breaks: Vec::new(),
|
||||
@@ -16194,6 +16284,7 @@ enum PendingWorldEntry {
|
||||
Server {
|
||||
address: String,
|
||||
name: String,
|
||||
lan: bool,
|
||||
/// A transfer's carried-over server cookies (`TransferState`).
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
},
|
||||
@@ -16291,8 +16382,9 @@ impl Game<'_> {
|
||||
address,
|
||||
name,
|
||||
cookies,
|
||||
lan,
|
||||
}) => {
|
||||
self.connect_server_with_cookies(&address, &name, cookies)?;
|
||||
self.connect_server_with_options(&address, &name, cookies, lan)?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
@@ -16381,18 +16473,24 @@ impl Game<'_> {
|
||||
}
|
||||
self.world_tables = self.build_world_tables();
|
||||
horizon_client::structure_blocks::forget_loot_data();
|
||||
lan::forget_sync_data();
|
||||
// PlayerList.reloadResources: the tags and recipes go to the players
|
||||
// and their advancements reload.
|
||||
if self.lan.is_some() {
|
||||
match lan::sync_data(&self.options.resources, &self.pack_files()) {
|
||||
Ok(sync) => {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.reload_resources(sync);
|
||||
// and their advancements reload. The synchronized registries (the
|
||||
// dialogs, and the commands' ids of them) stay the hub's.
|
||||
let kept = self.lan.as_ref().map(|host| host.hub.lan.shared.sync());
|
||||
match kept {
|
||||
Some(kept) => {
|
||||
match lan::reload_sync_data(&self.options.resources, &self.pack_files(), &kept) {
|
||||
Ok(sync) => {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.reload_resources(sync);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the players are not sent the reloaded data: {e}")
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: the players are not sent the reloaded data: {e}"),
|
||||
}
|
||||
None => lan::forget_sync_data(),
|
||||
}
|
||||
let overlay = pack_files(&self.world_tables);
|
||||
self.adv.tree = Arc::new(horizon_client::advancements::Tree::load_with(
|
||||
@@ -16597,7 +16695,12 @@ fn load_core(
|
||||
.map_err(|e| e.to_string());
|
||||
Ok((render, item_renderer))
|
||||
});
|
||||
// Nor the face visibility the mesher and the block entity
|
||||
// renderer cull with (19 MB).
|
||||
let visibility = Step::spawn(scope, parallel, || {
|
||||
if headless {
|
||||
return Ok(horizon_world::block_render::FaceVisibility::empty());
|
||||
}
|
||||
models::load_visibility(root, registry).map_err(|e| e.to_string())
|
||||
});
|
||||
let biomes = Step::spawn(scope, parallel, || {
|
||||
@@ -17083,6 +17186,9 @@ fn build_renderer(
|
||||
.iter()
|
||||
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
|
||||
.collect();
|
||||
renderer
|
||||
.hud
|
||||
.set_text_shader(&renderer.device, atlas.text_shader.as_deref());
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
@@ -18481,4 +18587,74 @@ mod tests {
|
||||
&HashSet::new()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_server_save_persists_owner_player_data() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-integrated-player-save-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// Initial world creation writes `level.dat` before the player spawns.
|
||||
let mut level = Level {
|
||||
name: "Creative".into(),
|
||||
creative: true,
|
||||
game_mode: 1,
|
||||
..Level::default()
|
||||
};
|
||||
level.write(&dir).unwrap();
|
||||
|
||||
// The integrated server stores the owner's state in `level` and saves the world.
|
||||
level.position = Some([128.5, 72.0, -64.25]);
|
||||
level.inventory = vec![(0, "minecraft:diamond_pickaxe".into(), 1, 0)];
|
||||
write_level_for_role(
|
||||
&mut level,
|
||||
&dir,
|
||||
execution_role::ExecutionRole::IntegratedServer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let reopened = Level::read(&dir).unwrap();
|
||||
assert_eq!(reopened.position, Some([128.5, 72.0, -64.25]));
|
||||
assert_eq!(
|
||||
reopened.inventory,
|
||||
vec![(0, "minecraft:diamond_pickaxe".into(), 1, 0)]
|
||||
);
|
||||
|
||||
// A dedicated server save updates world metadata without overwriting the player file.
|
||||
let mut dedicated = Level {
|
||||
player_uuid: level.player_uuid,
|
||||
day_time: 6000,
|
||||
..Level::default()
|
||||
};
|
||||
write_level_for_role(
|
||||
&mut dedicated,
|
||||
&dir,
|
||||
execution_role::ExecutionRole::DedicatedServer,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let reopened = Level::read(&dir).unwrap();
|
||||
assert_eq!(reopened.day_time, 6000);
|
||||
assert_eq!(reopened.position, Some([128.5, 72.0, -64.25]));
|
||||
assert_eq!(
|
||||
reopened.inventory,
|
||||
vec![(0, "minecraft:diamond_pickaxe".into(), 1, 0)]
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_level_for_role(
|
||||
level: &mut Level,
|
||||
dir: &std::path::Path,
|
||||
role: execution_role::ExecutionRole,
|
||||
) -> Result<()> {
|
||||
if role.writes_singleplayer_player() {
|
||||
level.write(dir)
|
||||
} else {
|
||||
level.write_without_player(dir)
|
||||
}
|
||||
}
|
||||
@@ -1424,6 +1424,38 @@ impl Game<'_> {
|
||||
adv::conditions(&c["location"], &ctx, w)
|
||||
});
|
||||
}
|
||||
/// `DefaultBlockInteractionTrigger` (default_block_use): the player, and
|
||||
/// the block's `BLOCK_USE` context at its centre, with no tool.
|
||||
pub(super) fn trigger_default_block_use(&mut self, pos: [i32; 3]) {
|
||||
self.trigger_block_use("minecraft:default_block_use", pos, None);
|
||||
}
|
||||
/// `AnyBlockInteractionTrigger` (any_block_use): the player, and the
|
||||
/// block's `ADVANCEMENT_LOCATION` context at its centre with the stack in
|
||||
/// hand as the use left it.
|
||||
pub(super) fn trigger_any_block_use(&mut self, pos: [i32; 3], stack: &ItemStack) {
|
||||
self.trigger_block_use("minecraft:any_block_use", pos, Some(stack));
|
||||
}
|
||||
fn trigger_block_use(&mut self, name: &str, pos: [i32; 3], stack: Option<&ItemStack>) {
|
||||
let me = self.player_entity();
|
||||
let it = stack.map(item_view);
|
||||
let centre = pos.map(|c| f64::from(c) + 0.5);
|
||||
self.trigger(name, move |c, w| {
|
||||
let player = adv::Entities {
|
||||
this: Some(&me),
|
||||
origin: me.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let location = adv::Entities {
|
||||
origin: centre,
|
||||
tool: it.as_ref(),
|
||||
block_pos: Some(pos),
|
||||
..player
|
||||
};
|
||||
adv::conditions(&c["player"], &player, w)
|
||||
&& adv::conditions(&c["location"], &location, w)
|
||||
});
|
||||
}
|
||||
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
|
||||
/// not hit (`bystander`), each tested from this player's position.
|
||||
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
|
||||
|
||||
@@ -424,6 +424,7 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug-options",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
|
||||
@@ -29,14 +29,17 @@ impl Game<'_> {
|
||||
.map(|(k, _)| *k)
|
||||
.collect()
|
||||
});
|
||||
let dancing = jukeboxes.iter().any(|k| {
|
||||
(f64::from(k.0) + 0.5 - pos.x).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
|
||||
<= 100.0
|
||||
});
|
||||
self.mobs[i].extra.flag =
|
||||
dancing && !horizon_client::mobs::goals::should_panic(&self.mobs[i]);
|
||||
let nearest = jukeboxes
|
||||
.iter()
|
||||
.map(|k| {
|
||||
(f64::from(k.0) + 0.5 - pos.x).powi(2)
|
||||
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
|
||||
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
// `Allay.setDancing` wants effective AI: a `NoAI` allay never dances.
|
||||
let panicking = horizon_client::mobs::goals::should_panic(&self.mobs[i]);
|
||||
self.mobs[i].extra.flag = allay::dances(self.mobs[i].no_ai, panicking, nearest);
|
||||
let Some(held) = self.mobs[i].extra.main_hand_stack().cloned() else {
|
||||
self.mobs[i].extra.point = None;
|
||||
continue;
|
||||
@@ -47,9 +50,23 @@ impl Game<'_> {
|
||||
.items()
|
||||
.get(&held.item)
|
||||
.map_or(64, |i| i.max_stack as i32);
|
||||
// GoToWantedItem / wantsToPickUp: a stack it considers equal within 32
|
||||
// that the allay sees (NearestItemSensor's hasLineOfSight, eye to eye).
|
||||
let wanted = if griefing && carried < max && self.mobs[i].extra.ints[3] <= 0 {
|
||||
// `Mob.aiStep`'s looting loop asks for no AI: `canPickUpLoot`, the
|
||||
// inventory room in `wantsToPickUp` and mob_griefing are its gates.
|
||||
let wants = self.mobs[i].can_pick_up_loot() && griefing && carried < max;
|
||||
let reach = allay::pickup_reach(self.mobs[i].bounds());
|
||||
// GoToWantedItem: a stack it considers equal within 32 that the
|
||||
// allay sees (NearestItemSensor's hasLineOfSight, eye to eye). The
|
||||
// sensor is the brain's, so without AI only `Mob.aiStep`'s looting
|
||||
// loop runs: it takes an equal drop already in `getPickupReach`.
|
||||
let wanted = if !wants {
|
||||
None
|
||||
} else if self.mobs[i].no_ai {
|
||||
self.dropped.iter().position(|e| {
|
||||
e.pickup_delay == 0
|
||||
&& allay::considers_item_equal(&held, &e.stack)
|
||||
&& reach.contains(e.position)
|
||||
})
|
||||
} else {
|
||||
let w = self.mob_world_base(&[]);
|
||||
let eye = horizon_core::geometry::Vec3::new(pos.x, self.mobs[i].eye_y(), pos.z);
|
||||
// The sensor looks in the allay's box grown by (32, 16, 32).
|
||||
@@ -78,20 +95,18 @@ impl Game<'_> {
|
||||
w.line_of_sight(eye, at)
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
} else {
|
||||
None
|
||||
.map(|(j, _)| j)
|
||||
};
|
||||
let Some((j, _)) = wanted else {
|
||||
let Some(j) = wanted else {
|
||||
self.mobs[i].extra.point = None;
|
||||
self.allay_deliver(i);
|
||||
// `GiveInventoryToLookTargetPlayer` is the brain's too.
|
||||
if !self.mobs[i].no_ai {
|
||||
self.allay_deliver(i);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let e = &self.dropped[j];
|
||||
// getPickupReach (1, 1, 1) around the box.
|
||||
let b = self.mobs[i]
|
||||
.bounds()
|
||||
.inflate(horizon_core::geometry::Vec3::new(1.0, 1.0, 1.0));
|
||||
if b.contains(e.position) {
|
||||
if reach.contains(e.position) {
|
||||
let take = (max - carried).min(e.stack.count as i32);
|
||||
let (item, id) = (e.clone(), self.mobs[i].id);
|
||||
// It carries the fetched stack, potion and all, not just its id.
|
||||
@@ -221,7 +236,7 @@ impl Game<'_> {
|
||||
self.spawn_item(stack, from, v, 10);
|
||||
// BehaviorUtils.throwItem: ItemEntity.setThrower(the allay).
|
||||
let allay = self.mob_uuid(self.mobs[i].id);
|
||||
if let Some(thrown) = self.dropped.last_mut() {
|
||||
if let Some(thrown) = self.just_spawned_item() {
|
||||
thrown.thrower = Some(allay);
|
||||
}
|
||||
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
|
||||
|
||||
@@ -707,14 +707,14 @@ impl Game<'_> {
|
||||
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
let in_water = a.in_water(&self.world, &self.res.motion().fluids);
|
||||
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
|
||||
let (world, registry, facts, collision) = (
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
a.tick(&mut scene, in_water, &mut |bounds, velocity| {
|
||||
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
|
||||
horizon_client::motion::bubble_column_push(
|
||||
world, registry, facts, collision, bounds, velocity,
|
||||
);
|
||||
@@ -1092,7 +1092,8 @@ fn swap_with_equipment_slot(
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `Equippable.swapWithEquipmentSlot`: right-clicking armor swaps it into
|
||||
/// its slot (the worn piece comes back to the hand).
|
||||
/// its slot (the worn piece comes back to the hand). It runs on a client
|
||||
/// too (`Item.use`), as the prediction the server's slot packets confirm.
|
||||
pub(super) fn equip_from_hand(&mut self, stack: &ItemStack) -> bool {
|
||||
let Some(info) = self.items().get(&stack.item) else {
|
||||
return false;
|
||||
@@ -1125,11 +1126,17 @@ impl Game<'_> {
|
||||
if let Some(old) = swap.to_inventory
|
||||
&& let Some(rest) = self.give(old)
|
||||
&& !self.creative
|
||||
// Player.drop is the server's on a client: it spawns the item.
|
||||
&& self.remote.is_none()
|
||||
&& let Some(eye) = self.eye_position()
|
||||
{
|
||||
self.spawn_item(rest, eye, [0.0; 3], 40);
|
||||
}
|
||||
self.apply_equipment();
|
||||
// The attribute modifiers of a worn piece come from the server's
|
||||
// ClientboundUpdateAttributesPacket on a client.
|
||||
if self.remote.is_none() {
|
||||
self.apply_equipment();
|
||||
}
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Placed banners (`BannerBlockEntity`): the pattern layers move from the
|
||||
//! banner item to the block when placed and back to the dropped item when
|
||||
//! broken (`copy_components` in the banner loot tables).
|
||||
use super::Game;
|
||||
use super::{Game, maps::MapBanner};
|
||||
use horizon_client::{
|
||||
banner::{self, BannerLayer},
|
||||
containers::BlockEntity,
|
||||
@@ -16,6 +16,38 @@ impl Game<'_> {
|
||||
};
|
||||
layer_textures(&self.stations().banners, &b.layers)
|
||||
}
|
||||
/// The banner at `pos` as a map marker source (`MapBanner.fromWorld`).
|
||||
pub(super) fn map_banner_at(&self, pos: [i32; 3]) -> Option<MapBanner> {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
map_banner(
|
||||
pos,
|
||||
self.world.block_entities.get(&(pos[0], pos[1], pos[2])),
|
||||
&self.res.facts().get(state).name,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// `MapBanner.fromWorld`: the banner whose block entity is `entity` at `pos`,
|
||||
/// with the base colour of its block (`BannerBlockEntity.getBaseColor` is
|
||||
/// `AbstractBannerBlock.getColor`) and its custom name; none when the block
|
||||
/// entity is not a banner.
|
||||
pub(super) fn map_banner(
|
||||
pos: [i32; 3],
|
||||
entity: Option<&BlockEntity>,
|
||||
block: &str,
|
||||
) -> Option<MapBanner> {
|
||||
let Some(BlockEntity::Banner(banner)) = entity else {
|
||||
return None;
|
||||
};
|
||||
let block = block.strip_prefix("minecraft:").unwrap_or(block);
|
||||
let color = block
|
||||
.strip_suffix("_wall_banner")
|
||||
.or_else(|| block.strip_suffix("_banner"))?;
|
||||
Some(MapBanner {
|
||||
pos,
|
||||
color: color.to_owned(),
|
||||
name: banner.name.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Texture names and tints of banner layers (`Sheets.getBannerSprite`).
|
||||
|
||||
@@ -877,16 +877,7 @@ impl Game<'_> {
|
||||
}
|
||||
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
|
||||
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
self.world.block_entities.insert(
|
||||
key,
|
||||
horizon_client::containers::BlockEntity::Chest(b.items.clone()),
|
||||
);
|
||||
self.container = Some(key);
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
self.open_chest_boat_container(i);
|
||||
self.swing_arm();
|
||||
return true;
|
||||
}
|
||||
@@ -904,6 +895,51 @@ impl Game<'_> {
|
||||
self.trigger_player("minecraft:started_riding");
|
||||
true
|
||||
}
|
||||
/// `ContainerEntity.interactWithContainerVehicle` (`AbstractChestBoat`):
|
||||
/// the chest's 27 slots go under a placeholder key that the menu edits; a
|
||||
/// server hands them to the player as a `ChestMenu` (`as_guest` ->
|
||||
/// `lan_open_container`). `AbstractChestBoat.interact` fires
|
||||
/// `CONTAINER_OPEN` and angers the piglins that see it.
|
||||
pub(super) fn open_chest_boat_container(&mut self, i: usize) {
|
||||
let b = &self.boats[i];
|
||||
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
let items = b.items.clone();
|
||||
self.world
|
||||
.block_entities
|
||||
.insert(key, horizon_client::containers::BlockEntity::Chest(items));
|
||||
self.container = Some(key);
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
let ctx = self.player_context(None);
|
||||
self.post_game_event("minecraft:container_open", at, ctx);
|
||||
self.anger_piglins(true);
|
||||
}
|
||||
/// `AbstractChestBoat.openCustomInventoryScreen` (`HasCustomInventoryScreen`),
|
||||
/// the inventory key while riding a chest boat: its chest opens, not the
|
||||
/// player's inventory.
|
||||
pub(super) fn open_ridden_chest_boat(&mut self) -> bool {
|
||||
let Some(id) = self.riding else {
|
||||
return false;
|
||||
};
|
||||
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
|
||||
return false;
|
||||
};
|
||||
if !self.boats[i].chest {
|
||||
return false;
|
||||
}
|
||||
self.open_chest_boat_container(i);
|
||||
true
|
||||
}
|
||||
/// Whether the player rides a chest boat: `HasCustomInventoryScreen`, so
|
||||
/// the inventory key sends `ServerboundPlayerCommandPacket.Action.OPEN_INVENTORY`
|
||||
/// (`ServerGamePacketListenerImpl.handlePlayerCommand` opens its screen).
|
||||
pub(super) fn riding_chest_boat(&self) -> bool {
|
||||
self.riding
|
||||
.and_then(|id| self.boats.iter().find(|b| b.id == id))
|
||||
.is_some_and(|b| b.chest)
|
||||
}
|
||||
/// `VehicleEntity.hurtServer` from the player's attack.
|
||||
pub(super) fn attack_boat(&mut self) -> bool {
|
||||
let Some((i, d)) = self.boat_hit() else {
|
||||
@@ -922,8 +958,11 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
|
||||
/// (`creative`: a creative player's hit removes it without drops).
|
||||
/// True when the boat was removed.
|
||||
/// (`creative`: a creative player's hit discards it straight away).
|
||||
/// `AbstractChestBoat.remove` drops the chest contents for every removal
|
||||
/// reason that destroys the boat (DISCARDED included), while the boat item
|
||||
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
|
||||
/// a creative hit discards it. True when the boat was removed.
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_dir = -b.hurt_dir;
|
||||
@@ -936,11 +975,15 @@ impl Game<'_> {
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// AbstractBoat.remove: a destroyed boat drops its lead.
|
||||
self.boat_removed(&b);
|
||||
// `AbstractChestBoat.remove` spills the chest through
|
||||
// `Containers.dropContents` for every destroying removal reason;
|
||||
// that has no `entity_drops` gate of its own.
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
// `VehicleEntity.destroy(level, dropItem)` drops the boat item on
|
||||
// the death path only, and only with `entity_drops` on.
|
||||
if !creative && self.ui.rules.bool("entity_drops") {
|
||||
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
|
||||
for stack in b.items.iter().flatten() {
|
||||
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
let item = b.entity_type();
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
|
||||
@@ -191,7 +191,8 @@ impl MapData {
|
||||
m.unlimited = self.unlimited;
|
||||
m
|
||||
}
|
||||
/// `MapItemSavedData.locked`: the same pixels and banner markers, locked.
|
||||
/// `MapItemSavedData.locked`: the same pixels, banner markers and
|
||||
/// decorations, locked (the frame markers are not copied).
|
||||
pub(super) fn locked_copy(&self) -> MapData {
|
||||
let mut m = MapData::fresh(
|
||||
f64::from(self.center[0]),
|
||||
@@ -202,6 +203,7 @@ impl MapData {
|
||||
m.center = self.center;
|
||||
m.colors = self.colors.clone();
|
||||
m.banners = self.banners.clone();
|
||||
m.decorations = self.decorations.clone();
|
||||
m.tracking = self.tracking;
|
||||
m.unlimited = self.unlimited;
|
||||
m.locked = true;
|
||||
@@ -212,7 +214,7 @@ impl MapData {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::maps::Decoration;
|
||||
use crate::game::maps::{Decoration, Key};
|
||||
|
||||
fn close(a: [[f32; 2]; 4], b: [[f32; 2]; 4]) {
|
||||
for (a, b) in a.iter().zip(&b) {
|
||||
@@ -239,7 +241,8 @@ mod tests {
|
||||
x,
|
||||
y,
|
||||
rot,
|
||||
holder: None,
|
||||
name: None,
|
||||
key: Key::Item,
|
||||
};
|
||||
// The centre, facing north: 8 map pixels wide, nudged by (-0.5, 0.5).
|
||||
close(
|
||||
|
||||
@@ -87,6 +87,9 @@ impl Game<'_> {
|
||||
return None;
|
||||
}
|
||||
if !occupied {
|
||||
// useWithoutItem's CONSUME on an empty slot still consumes the
|
||||
// click (ServerPlayerGameMode.useItemOn's DEFAULT_BLOCK_USE).
|
||||
self.trigger_default_block_use(pos);
|
||||
return Some(false);
|
||||
}
|
||||
// removeBook: the book goes to the inventory, or is dropped.
|
||||
@@ -100,6 +103,8 @@ impl Game<'_> {
|
||||
self.audio.play(sound, Some(centre), 1.0, 1.0);
|
||||
self.give_or_drop(retrieved);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
// ServerPlayerGameMode.useItemOn: useWithoutItem consumed the click.
|
||||
self.trigger_default_block_use(pos);
|
||||
Some(true)
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ impl Game<'_> {
|
||||
.effects
|
||||
.iter()
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.filter_map(|e| effect_particle(e.0, false))
|
||||
.filter_map(|e| effect_particle(e.0, m.extra.ambient_effects.contains(&e.0)))
|
||||
.collect();
|
||||
swirl(
|
||||
&mut self.particles,
|
||||
|
||||
@@ -91,6 +91,9 @@ pub(super) struct CombatTracker {
|
||||
/// The projectile hitting the player now is another player's that
|
||||
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
|
||||
pub(super) refuse_player_hit: bool,
|
||||
/// A firework rocket's burst is hurting the player now: the rocket is
|
||||
/// the direct entity (`rocket_hurt_player`).
|
||||
pub(super) rocket_hit: bool,
|
||||
/// The player whose melee hit is landing on the player now: where they
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
@@ -112,6 +115,10 @@ pub(super) struct CombatTracker {
|
||||
/// skull, wither or TNT minecart, the sources that empty a hive they
|
||||
/// destroy (`BeehiveBlock.getDrops`).
|
||||
pub(super) hive_emptying_blast: bool,
|
||||
/// The blast now going off comes from a primed TNT minecart, which
|
||||
/// neither resists on nor breaks a rail or the block under one
|
||||
/// (`MinecartTNT.getBlockExplosionResistance`, `shouldBlockExplode`).
|
||||
pub(super) rail_proof_blast: bool,
|
||||
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
|
||||
/// the mobs the blast hurts record it as their attacker.
|
||||
pub(super) explosion_owner: Option<u32>,
|
||||
@@ -329,31 +336,32 @@ impl Game<'_> {
|
||||
.find(|m| m.id == *id)
|
||||
.map(|m| self.mob_entity(m))
|
||||
});
|
||||
let projectile = matches!(
|
||||
e.damage_type.as_str(),
|
||||
"minecraft:arrow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:mob_projectile"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:unattributed_fireball"
|
||||
| "minecraft:wither_skull"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:thrown"
|
||||
| "minecraft:spit"
|
||||
);
|
||||
let direct = if projectile {
|
||||
Some(EntityView {
|
||||
kind: e.damage_type.clone(),
|
||||
let projectile = match e.damage_type.as_str() {
|
||||
// The rocket is the direct entity of its `fireworks` damage.
|
||||
"minecraft:fireworks" => Some("minecraft:firework_rocket"),
|
||||
t @ ("minecraft:arrow"
|
||||
| "minecraft:trident"
|
||||
| "minecraft:mob_projectile"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:unattributed_fireball"
|
||||
| "minecraft:wither_skull"
|
||||
| "minecraft:wind_charge"
|
||||
| "minecraft:thrown"
|
||||
| "minecraft:spit") => Some(t),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
Some(kind) => Some(EntityView {
|
||||
kind: kind.to_owned(),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
source.clone()
|
||||
}),
|
||||
None => source.clone(),
|
||||
};
|
||||
Some(DamageView {
|
||||
kind: e.damage_type.clone(),
|
||||
direct,
|
||||
source,
|
||||
is_direct: !projectile,
|
||||
is_direct: projectile.is_none(),
|
||||
original: e.damage,
|
||||
actual: e.damage,
|
||||
blocked: false,
|
||||
@@ -561,9 +569,15 @@ impl Game<'_> {
|
||||
.map(|e| {
|
||||
let attacker = e.source.attacker.and_then(|w| name(self, w));
|
||||
// AbstractArrow.onHitEntity: an arrow nobody shot is its own
|
||||
// cause.
|
||||
let direct = (attacker.is_none() && e.source.kind == DamageKind::Arrow)
|
||||
.then(|| Text::translate("entity.minecraft.arrow", &[]));
|
||||
// cause; a rocket nobody fired still names itself.
|
||||
let direct = match e.source.kind {
|
||||
_ if attacker.is_some() => None,
|
||||
DamageKind::Arrow => Some(Text::translate("entity.minecraft.arrow", &[])),
|
||||
DamageKind::Fireworks => {
|
||||
Some(Text::translate("entity.minecraft.firework_rocket", &[]))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Entry {
|
||||
damage_type: super::creatures::source_damage_type(e.source).to_owned(),
|
||||
damage: e.damage,
|
||||
|
||||
@@ -1305,7 +1305,7 @@ impl Game<'_> {
|
||||
p[2],
|
||||
]);
|
||||
g.throw_from_eyes(rest, eye, false);
|
||||
if let Some(item) = g.dropped.last_mut() {
|
||||
if let Some(item) = g.just_spawned_item() {
|
||||
item.thrower = None;
|
||||
item.pickup_delay = 0;
|
||||
item.target = uuid;
|
||||
@@ -1934,7 +1934,13 @@ impl Game<'_> {
|
||||
self.creatures.projectiles.retain(|p| p.id != id);
|
||||
}
|
||||
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
|
||||
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
|
||||
Target::Frame(id) => {
|
||||
// ItemFrame.onKilled.
|
||||
if let Some(frame) = self.item_frames.iter().find(|f| f.id == id).cloned() {
|
||||
self.remove_framed_map(&frame);
|
||||
}
|
||||
self.item_frames.retain(|f| f.id != id);
|
||||
}
|
||||
Target::Painting(id) => self.paintings.retain(|p| p.id != id),
|
||||
Target::Boat(id) => {
|
||||
// AbstractBoat.remove: a killed boat drops its lead.
|
||||
|
||||
@@ -149,8 +149,9 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
|
||||
/// rebuilt from its compound, keeping its id and UUID.
|
||||
fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
|
||||
/// rebuilt from its compound, keeping its id and UUID. Also what
|
||||
/// `TypedEntityData.loadInto` is: a spawn egg's data on the mob it spawned.
|
||||
pub(in crate::game) fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
|
||||
let stale = || err("commands.data.merge.failed", &[]);
|
||||
match t {
|
||||
Target::Player => return Err(err("commands.data.entity.invalid", &[])),
|
||||
@@ -165,8 +166,13 @@ impl Game<'_> {
|
||||
if let Some(k) = kind {
|
||||
set_key(&mut tag, "id", Tag::String(k.as_str().into()));
|
||||
}
|
||||
let new =
|
||||
entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), id).ok_or_else(stale)?;
|
||||
let new = entity_nbt::mob_from_nbt(
|
||||
&tag,
|
||||
self.res.mob_types(),
|
||||
id,
|
||||
Some(self.pack.assets.registry),
|
||||
)
|
||||
.ok_or_else(stale)?;
|
||||
if let Some(slot) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
// Runtime-only state (AI, animation) carries over.
|
||||
let old = std::mem::replace(slot, new);
|
||||
|
||||
@@ -73,11 +73,8 @@ impl Game<'_> {
|
||||
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
Some(m) => {
|
||||
let name = namespaced(effect.id);
|
||||
m.add_effect(name, duration, amplifier);
|
||||
m.extra.hidden_effects.retain(|h| *h != name);
|
||||
if !particles {
|
||||
m.extra.hidden_effects.push(name);
|
||||
}
|
||||
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
|
||||
m.add_effect_with(name, duration, amplifier, false, particles);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
@@ -144,6 +141,9 @@ impl Game<'_> {
|
||||
m.extra
|
||||
.hidden_effects
|
||||
.retain(|h| left.iter().any(|x| x.0 == *h));
|
||||
m.extra
|
||||
.ambient_effects
|
||||
.retain(|h| left.iter().any(|x| x.0 == *h));
|
||||
m.extra.effects.len() != before
|
||||
}
|
||||
None => false,
|
||||
|
||||
@@ -14,6 +14,9 @@ use horizon_world::nbt::Tag;
|
||||
pub(in crate::game) enum Slot {
|
||||
/// `inventory[0..36]`: hotbar then the main rows.
|
||||
Inv(usize),
|
||||
/// The selected hotbar slot as `weapon.mainhand` names it: the equipment's
|
||||
/// `setItemSlot`, where `hotbar.N` is the inventory's `setItem`.
|
||||
MainHand,
|
||||
/// `armor[0..4]` (head, chest, legs, feet).
|
||||
Armor(usize),
|
||||
Offhand,
|
||||
@@ -114,7 +117,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if matches_name(name, "weapon") || matches_name(name, "weapon.mainhand") {
|
||||
add(Slot::Inv(self.selected));
|
||||
add(Slot::MainHand);
|
||||
}
|
||||
if matches_name(name, "weapon.offhand") {
|
||||
add(Slot::Offhand);
|
||||
@@ -175,6 +178,7 @@ impl Game<'_> {
|
||||
.for_player(Target::Guest(*conn), |g| g.slot_get(slot))
|
||||
.flatten(),
|
||||
Slot::Inv(i) => self.inventory.get(*i).cloned().flatten(),
|
||||
Slot::MainHand => self.inventory.get(self.selected).cloned().flatten(),
|
||||
Slot::Armor(i) => self.armor.get(*i).cloned().flatten(),
|
||||
Slot::Offhand => self.offhand.clone(),
|
||||
Slot::Ender(i) => self.ender_items.get(*i).cloned().flatten(),
|
||||
@@ -227,6 +231,14 @@ impl Game<'_> {
|
||||
None => false,
|
||||
},
|
||||
// LivingEntity's equipment SlotAccess: setItemSlot, so onEquipItem.
|
||||
Slot::MainHand => match self.inventory.get_mut(self.selected) {
|
||||
Some(slot) => {
|
||||
let old = std::mem::replace(slot, stack.clone());
|
||||
self.player_on_equip("mainhand", old.as_ref(), stack.as_ref());
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
Slot::Armor(i) => match self.armor.get_mut(*i) {
|
||||
Some(slot) => {
|
||||
let old = std::mem::replace(slot, stack.clone());
|
||||
|
||||
@@ -345,8 +345,11 @@ impl Game<'_> {
|
||||
if action == "tap" {
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// The merchant's focused offer takes Enter and Space, as the window's.
|
||||
if action != "up" && self.screen == Screen::Inventory && self.merchant_key(physical)
|
||||
// The merchant's focused offer and the recipe book's last
|
||||
// recipe take Enter and Space, as the window's.
|
||||
if action != "up"
|
||||
&& self.screen == Screen::Inventory
|
||||
&& (self.merchant_key(physical) || self.recipe_book_key(physical))
|
||||
{
|
||||
return ok();
|
||||
}
|
||||
@@ -530,8 +533,12 @@ impl Game<'_> {
|
||||
}
|
||||
"lod-distance" => {
|
||||
let n: u32 = arg(1)?.parse()?;
|
||||
if n > 1024 {
|
||||
return Err("LOD distance must be 0..1024".into());
|
||||
if n > horizon_client::lod::MAX_CHUNKS {
|
||||
return Err(format!(
|
||||
"LOD distance must be 0..{}",
|
||||
horizon_client::lod::MAX_CHUNKS
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.settings.horizon.lod_distance = n;
|
||||
self.apply_settings();
|
||||
@@ -635,6 +642,7 @@ impl Game<'_> {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
Screen::BlockEditor => self.block_editor_type(text),
|
||||
Screen::DebugOptions => self.debug_options_type(text),
|
||||
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
|
||||
// The port, or the game rules' search and number boxes.
|
||||
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
|
||||
@@ -914,6 +922,7 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::BlockEditor => "block-editor",
|
||||
Screen::Win => "win",
|
||||
Screen::DebugOptions => "debug-options",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1032,6 +1041,7 @@ impl Game<'_> {
|
||||
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
|
||||
"far_detail": self.settings.horizon.far_detail.name(),
|
||||
"resident_quads": self.renderer.as_ref().map_or(0, |r| r.lod_quads()),
|
||||
"levels": self.renderer.as_ref().map_or_else(Vec::new, |r| r.lod_levels()),
|
||||
"pending_observations": self.lod_observe.len(),
|
||||
"refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()),
|
||||
});
|
||||
@@ -1045,8 +1055,8 @@ impl Game<'_> {
|
||||
"screen": self.screen_name(),
|
||||
"debug": {
|
||||
"overlay": self.debug,
|
||||
"hitboxes": self.show_hitboxes,
|
||||
"chunk_borders": self.show_chunk_borders,
|
||||
"hitboxes": self.debug_entry("entity_hitboxes"),
|
||||
"chunk_borders": self.debug_entry("chunk_borders"),
|
||||
"pause_menu": self.pause_menu,
|
||||
"advanced_tooltips": self.settings.file.bool("advancedItemTooltips"),
|
||||
"pause_on_lost_focus": self.settings.file.bool("pauseOnLostFocus"),
|
||||
|
||||
@@ -17,6 +17,7 @@ use horizon_client::{
|
||||
farm::Climate,
|
||||
spawning::{self, BiomeFacts},
|
||||
},
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
|
||||
text::Text,
|
||||
};
|
||||
@@ -390,6 +391,7 @@ impl Game<'_> {
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
self.enchant_spawn_equipment(id);
|
||||
self.halloween_spawn_head(id);
|
||||
if !persistent && !scripted {
|
||||
self.spawn_jockeys(id, true);
|
||||
}
|
||||
@@ -571,6 +573,7 @@ impl Game<'_> {
|
||||
block_tags: &self.items().block_tags,
|
||||
biomes: self.res.biomes(),
|
||||
mob_griefing: self.ui.rules.bool("mob_griefing"),
|
||||
universal_anger: self.ui.rules.bool("universal_anger"),
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
|
||||
end: self.dim == Dim::End,
|
||||
@@ -723,6 +726,7 @@ impl Game<'_> {
|
||||
.map(super::boat_leads::boat_state)
|
||||
.collect();
|
||||
let base = self.mob_world_base(&[]);
|
||||
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
|
||||
// Each mob keeps its snapshot slot all tick; the first slot of an
|
||||
// id answers, as a scan would.
|
||||
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
|
||||
@@ -783,7 +787,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
|
||||
horizon_client::mobs::blocks_changed(m, &w, &path_writes);
|
||||
horizon_client::mobs::blocks_changed(m, &path_changes);
|
||||
let from = events.len();
|
||||
let kept = m.tick(&w, &mut scratch, &mut events);
|
||||
// A killed mob's body is removed: its effects that act on
|
||||
@@ -888,6 +892,7 @@ impl Game<'_> {
|
||||
self.tick_fangs();
|
||||
self.tick_allays();
|
||||
self.tick_pandas();
|
||||
self.tick_mob_looting();
|
||||
self.tick_breeze_effects();
|
||||
self.tick_sniffer_effects();
|
||||
self.piglin_pickups();
|
||||
@@ -1053,6 +1058,7 @@ impl Game<'_> {
|
||||
let projectile = match d.kind {
|
||||
DamageKind::Arrow => Some("minecraft:arrow"),
|
||||
DamageKind::Fireball => Some("minecraft:fireball"),
|
||||
DamageKind::Fireworks => Some("minecraft:firework_rocket"),
|
||||
_ => None,
|
||||
};
|
||||
let direct = match projectile {
|
||||
@@ -1154,6 +1160,10 @@ impl Game<'_> {
|
||||
kind: "minecraft:fireball".into(),
|
||||
..LootEntity::default()
|
||||
}),
|
||||
DamageKind::Fireworks => Some(LootEntity {
|
||||
kind: "minecraft:firework_rocket".into(),
|
||||
..LootEntity::default()
|
||||
}),
|
||||
_ => source.clone(),
|
||||
};
|
||||
LootDamage {
|
||||
@@ -1538,12 +1548,20 @@ impl Game<'_> {
|
||||
self.combat.explosion_source = Some(mob);
|
||||
// The creeper causes the blast's damage, but it is out of
|
||||
// the mob list already: its name comes with the event.
|
||||
self.combat.exploded.push((mob, name.clone()));
|
||||
self.note_named_attacker("minecraft:explosion", mob, name);
|
||||
self.combat.exploded.push((mob, name));
|
||||
if charged {
|
||||
self.combat.charged_exploders.push(mob);
|
||||
}
|
||||
}
|
||||
MobEvent::LingeringCloud {
|
||||
at,
|
||||
effects,
|
||||
hidden,
|
||||
ambient,
|
||||
color,
|
||||
} => {
|
||||
self.spawn_creeper_cloud(at, effects, hidden, ambient, color);
|
||||
}
|
||||
MobEvent::Explode(at, radius, species) => {
|
||||
// The exploding mob causes the damage (Creeper.explodeCreeper).
|
||||
let noted = self.combat.explosion_source.is_some();
|
||||
@@ -1557,9 +1575,6 @@ impl Game<'_> {
|
||||
.map(|m| m.id)
|
||||
.next()
|
||||
});
|
||||
if !noted && let Some(id) = source_mob {
|
||||
self.note_attacker("minecraft:explosion", id);
|
||||
}
|
||||
self.combat.explosion_source = source_mob;
|
||||
self.combat.explosion_source_named = noted;
|
||||
self.combat.explosion_cause = match species {
|
||||
@@ -2675,17 +2690,21 @@ impl Game<'_> {
|
||||
damage_type: &str,
|
||||
known: Option<Option<u32>>,
|
||||
) -> bool {
|
||||
let rocket = std::mem::take(&mut self.combat.rocket_hit);
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
return false;
|
||||
}
|
||||
let projectile = self
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
// A projectile entity dealt it: `#is_projectile`, or a firework
|
||||
// rocket (its `fireworks` type is not in the tag).
|
||||
let projectile = rocket
|
||||
|| self
|
||||
.res
|
||||
.stations()
|
||||
.enchantments
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
let blocking = self.player_blocking(from, damage_type, false);
|
||||
let raised = blocking.is_some();
|
||||
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
|
||||
@@ -2766,7 +2785,17 @@ impl Game<'_> {
|
||||
.map(|m| self.mob_entity(m));
|
||||
self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView {
|
||||
kind: damage_type.to_owned(),
|
||||
direct: source.clone().filter(|_| !projectile),
|
||||
// Arrows and the like name no direct entity here; a
|
||||
// rocket's burst names the rocket.
|
||||
direct: if rocket {
|
||||
Some(horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:firework_rocket".to_owned(),
|
||||
position: from.unwrap_or_default(),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
source.clone().filter(|_| !projectile)
|
||||
},
|
||||
is_direct: !projectile,
|
||||
source,
|
||||
original: amount,
|
||||
@@ -3934,6 +3963,13 @@ impl Game<'_> {
|
||||
Hit::None => {}
|
||||
},
|
||||
(ProjectileKind::LargeFireball { power }, hit) => {
|
||||
// Fireball explosions are caused by the shooter. A direct hit
|
||||
// on the player takes `projectile_owner`, so read the owner
|
||||
// from the fireball.
|
||||
let owner = match p.owner {
|
||||
Some(Who::Mob(id)) => Some(id),
|
||||
_ => None,
|
||||
};
|
||||
if let Hit::Entity { who, .. } = hit {
|
||||
match who {
|
||||
Who::Player => {
|
||||
@@ -3955,11 +3991,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fireball explosions are caused by the shooter.
|
||||
if let Some(owner) = self.combat.projectile_owner {
|
||||
self.note_attacker("minecraft:explosion", owner);
|
||||
}
|
||||
self.combat.explosion_owner = self.combat.projectile_owner;
|
||||
self.combat.explosion_owner = owner;
|
||||
// The blast's source names the owner, so a dragon it kills
|
||||
// remembers the player who shot it.
|
||||
self.set_blast_by_player(p.owner == Some(Who::Player));
|
||||
@@ -4395,6 +4427,16 @@ impl Game<'_> {
|
||||
let mut drops: Vec<(String, u32)> = Vec::new();
|
||||
let is_food = m.kind.food.contains(&item);
|
||||
let baby_turtle = species == Species::Turtle && m.is_baby();
|
||||
// MushroomCow.getEffectsFromItemStack: the item's block supplies them.
|
||||
let flower = if species == Species::Mooshroom {
|
||||
horizon_client::flowers::stew_effect(&item)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut give_stew: Option<Vec<(Arc<str>, i32)>> = None;
|
||||
// `Some(true)` for the four spell particles, `Some(false)` for the two
|
||||
// smoke puffs of a cow that already holds effects.
|
||||
let mut stew_particles: Option<bool> = None;
|
||||
match species {
|
||||
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
|
||||
give = Some("minecraft:milk_bucket");
|
||||
@@ -4411,10 +4453,22 @@ impl Game<'_> {
|
||||
"entity.goat.milk"
|
||||
});
|
||||
}
|
||||
// MushroomCow.mobInteract: a bowl gives back what a flower left as
|
||||
// a suspicious stew, or plain stew when it holds no effects.
|
||||
Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => {
|
||||
give = Some("minecraft:mushroom_stew");
|
||||
consume = true;
|
||||
sound = Some("entity.mooshroom.milk");
|
||||
match m.extra.stew_effects.take() {
|
||||
Some(effects) => {
|
||||
give = Some("minecraft:suspicious_stew");
|
||||
give_stew = Some(effects);
|
||||
// `playSound(.., 1.0F, 1.0F)` at the feet.
|
||||
eat = Some(("entity.mooshroom.suspicious_milk", 1.0, 1.0));
|
||||
}
|
||||
None => {
|
||||
give = Some("minecraft:mushroom_stew");
|
||||
sound = Some("entity.mooshroom.milk");
|
||||
}
|
||||
}
|
||||
}
|
||||
Species::Mooshroom if item == "minecraft:shears" && !m.is_baby() => {
|
||||
// MushroomCow.shear: becomes a cow and drops five mushrooms.
|
||||
@@ -4439,6 +4493,19 @@ impl Game<'_> {
|
||||
}])?;
|
||||
self.damage_held_tool();
|
||||
}
|
||||
// MushroomCow.mobInteract: a brown adult takes the flower's effects
|
||||
// unless it holds some already, when it only puffs smoke.
|
||||
Species::Mooshroom if m.extra.variant == 1 && !m.is_baby() && flower.is_some() => {
|
||||
if m.extra.stew_effects.is_some() {
|
||||
stew_particles = Some(false);
|
||||
} else {
|
||||
let (effect, ticks) = flower.expect("the guard checked");
|
||||
m.extra.stew_effects = Some(vec![(Arc::from(effect), ticks)]);
|
||||
consume = true;
|
||||
eat = Some(("entity.mooshroom.eat", 2.0, 1.0));
|
||||
stew_particles = Some(true);
|
||||
}
|
||||
}
|
||||
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
|
||||
m.extra.sheared = true;
|
||||
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
|
||||
@@ -4830,6 +4897,31 @@ impl Game<'_> {
|
||||
if !handled {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(fresh) = stew_particles {
|
||||
// `SpellParticleOption.create(EFFECT, -1, 1)` is the plain white
|
||||
// effect particle; the smoke is the already-fed answer.
|
||||
let ty = if fresh {
|
||||
ParticleType::Effect
|
||||
} else {
|
||||
ParticleType::Smoke
|
||||
};
|
||||
let options = ParticleOptions::simple(ty);
|
||||
let at = [feet[0], feet[1] + 0.5, feet[2]];
|
||||
let (spread, count) = ([0.5, 0.0, 0.5], if fresh { 4 } else { 2 });
|
||||
// shortcut: scalar speed models DEFAULT randomization; 26.3 sends
|
||||
// ALTERNATIVE_WITH_SPEED with a per-axis maximum speed.
|
||||
self.particles
|
||||
.send_particles(&options, at, spread, 0.2, count);
|
||||
self.lan_send_particles(super::lan_particles::QueuedParticle {
|
||||
dimension: self.dim.id(),
|
||||
options,
|
||||
state: None,
|
||||
at,
|
||||
spread,
|
||||
speed: 0.2,
|
||||
count: count as i32,
|
||||
});
|
||||
}
|
||||
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
|
||||
if let Some(stack) = &held {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
@@ -4864,12 +4956,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if let Some(g) = give {
|
||||
let stack = ItemStack {
|
||||
let mut stack = ItemStack {
|
||||
item: g.into(),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
if let Some(effects) = give_stew {
|
||||
stack.extra = Some(Box::new(horizon_client::item_data::StackData {
|
||||
stew_effects: effects,
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
if self.stack_in(selected).is_none() {
|
||||
*self.stack_in_mut(selected) = Some(stack);
|
||||
} else if let Some(rest) = self.give(stack) {
|
||||
@@ -5120,6 +5218,7 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.apply_spawn_egg_entity_data(id, stack);
|
||||
self.swing_arm();
|
||||
if !self.creative
|
||||
&& let Some(s) = self.held_mut().as_mut()
|
||||
@@ -5131,6 +5230,25 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
|
||||
/// the mob it spawned, once the guards let it through, as
|
||||
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
|
||||
/// it, loaded back into the same mob (`/data merge entity`'s loader).
|
||||
fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let kind = self.mobs[i].kind.id.clone();
|
||||
let op = self.acting_player_is_op();
|
||||
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let own = self.mob_tree_nbt(i);
|
||||
let tag = horizon_client::data_cmd::merged(&own, data);
|
||||
// Nothing to report: the mob's own compound always loads back.
|
||||
let _ = self.apply_entity_nbt(super::commands::Target::Mob(id), tag);
|
||||
}
|
||||
/// A beam target's centre (half its height), interpolated.
|
||||
fn beam_target(&self, who: Option<Who>, alpha: f64) -> Option<glam::DVec3> {
|
||||
match who? {
|
||||
@@ -6035,6 +6153,7 @@ mod pearl_tests {
|
||||
block_tags: &items.block_tags,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
mob_drops: true,
|
||||
max_entity_cramming: 24,
|
||||
difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0),
|
||||
|
||||
@@ -278,14 +278,26 @@ impl Game<'_> {
|
||||
format!("To edit: press {}", key("key.debug.debugOptions")),
|
||||
]
|
||||
}
|
||||
/// `DebugScreenOverlay.extractRenderState` with the default profile's
|
||||
/// entries, over everything the frame drew (menus too), at the debug GUI
|
||||
/// scale. F1 hides it unless a screen is open.
|
||||
/// `Gui.extractRenderState`'s debug overlay, over everything the frame
|
||||
/// drew (menus too); the debug options screen draws it under itself.
|
||||
pub(super) fn draw_debug_overlay(&self, batch: &mut HudBatch, size: [u32; 2]) {
|
||||
if self.screen != Screen::DebugOptions {
|
||||
self.draw_debug_overlay_under(batch, size);
|
||||
}
|
||||
}
|
||||
/// `DebugScreenOverlay.extractRenderState`: the enabled entries' lines
|
||||
/// (`debug-profile.json`), at the debug GUI scale, and the charts while
|
||||
/// the overlay is open. F1 hides it unless a screen is open.
|
||||
pub(super) fn draw_debug_overlay_under(&self, batch: &mut HudBatch, size: [u32; 2]) {
|
||||
let screen_open = self.screen != Screen::Playing;
|
||||
if self.title.is_some() || !self.debug || (self.ui.hide_gui && !screen_open) {
|
||||
let reduced = self.reduced_debug_info();
|
||||
if self.title.is_some()
|
||||
|| !self.debug_entries.any_enabled(self.debug, reduced)
|
||||
|| (self.ui.hide_gui && !screen_open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let on = |path: &str| self.debug_entries.enabled(path, self.debug, reduced);
|
||||
let framebuffer = size.map(|v| v as i32);
|
||||
let (scale, [width, height]) = debug_overlay::scale(
|
||||
framebuffer,
|
||||
@@ -296,6 +308,83 @@ impl Game<'_> {
|
||||
);
|
||||
let mut d = debug_overlay::Displayer::default();
|
||||
// fps: DebugEntryFps.
|
||||
if on("fps") {
|
||||
self.debug_fps_line(&mut d);
|
||||
}
|
||||
// game_version: DebugEntryVersion (the launched version and brand).
|
||||
if on("game_version") {
|
||||
d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)"));
|
||||
}
|
||||
// player_position, player_section_position.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let mut lines = debug_overlay::position(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim.id(),
|
||||
self.streaming.forced.len(),
|
||||
);
|
||||
let section = lines.pop().unwrap_or_default();
|
||||
if on("player_position") {
|
||||
d.group("position", lines);
|
||||
}
|
||||
if on("player_section_position") {
|
||||
d.group("position", [section]);
|
||||
}
|
||||
}
|
||||
if on("simple_performance_impactors") {
|
||||
self.debug_performance_lines(&mut d);
|
||||
}
|
||||
if on("system_specs") {
|
||||
self.debug_system_lines(&mut d, framebuffer);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
});
|
||||
if self.debug && !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
if on("tps") {
|
||||
self.debug_tps_line(&mut d);
|
||||
}
|
||||
// The chart keys and the charts only while the overlay is open.
|
||||
let footer = if self.debug {
|
||||
self.debug_chart_lines().to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let (left, right) = d.columns(&footer);
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut draws = debug_overlay::draw(&atlas.font, [&left, &right], scale, width);
|
||||
if self.debug {
|
||||
let charts = self.debug_charts.draw(
|
||||
&atlas.font,
|
||||
width,
|
||||
height,
|
||||
self.settings.max_fps,
|
||||
1000.0 / self.clock.rate(),
|
||||
!self.has_singleplayer_server(),
|
||||
);
|
||||
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
|
||||
scale, scale,
|
||||
)]));
|
||||
draws.extend(charts);
|
||||
if self.debug_charts.lightmap
|
||||
&& let Some(r) = &self.renderer
|
||||
{
|
||||
draws.extend(horizon_client::debug_chart::lightmap(
|
||||
&r.lightmap_pixels,
|
||||
width,
|
||||
height,
|
||||
));
|
||||
}
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
}
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
/// `DebugEntryFps`.
|
||||
fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) {
|
||||
let limit = self.framerate_limit();
|
||||
let present = self
|
||||
.renderer
|
||||
@@ -324,24 +413,9 @@ impl Game<'_> {
|
||||
limit.to_string()
|
||||
},
|
||||
));
|
||||
// game_version: DebugEntryVersion (the launched version and brand).
|
||||
d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)"));
|
||||
// player_position, player_section_position.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let mut lines = debug_overlay::position(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
self.yaw,
|
||||
self.pitch,
|
||||
self.dim.id(),
|
||||
self.streaming.forced.len(),
|
||||
);
|
||||
let section = lines.pop().unwrap_or_default();
|
||||
if !self.reduced_debug_info() {
|
||||
d.group("position", lines);
|
||||
}
|
||||
d.group("position", [section]);
|
||||
}
|
||||
// simple_performance_impactors.
|
||||
}
|
||||
/// `DebugEntrySimplePerformanceImpactors`.
|
||||
fn debug_performance_lines(&self, d: &mut debug_overlay::Displayer) {
|
||||
let s = &self.settings;
|
||||
d.line(format!(
|
||||
"{}{}B: {}",
|
||||
@@ -366,7 +440,9 @@ impl Game<'_> {
|
||||
"naive"
|
||||
}
|
||||
));
|
||||
// system_specs (no Java line: Horizon has no JVM).
|
||||
}
|
||||
/// `DebugEntrySystemSpecs` (no Java line: Horizon has no JVM).
|
||||
fn debug_system_lines(&self, d: &mut debug_overlay::Displayer, framebuffer: [i32; 2]) {
|
||||
if let Some(r) = self.renderer.as_ref() {
|
||||
static CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
d.group(
|
||||
@@ -432,14 +508,9 @@ impl Game<'_> {
|
||||
{
|
||||
d.group("system", [r.hdr_line()]);
|
||||
}
|
||||
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
|
||||
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
|
||||
debug_overlay::shader_pass_lines(&r.shader_pass_times())
|
||||
});
|
||||
if !pass_lines.is_empty() {
|
||||
d.group("shader pack", pass_lines);
|
||||
}
|
||||
// tps: DebugEntryTps for local and remote connections.
|
||||
}
|
||||
/// `DebugEntryTps` for local and remote connections.
|
||||
fn debug_tps_line(&self, d: &mut debug_overlay::Displayer) {
|
||||
if self.player.is_some() {
|
||||
let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64);
|
||||
let (target, status) = if self.clock.sprinting() {
|
||||
@@ -471,32 +542,6 @@ impl Game<'_> {
|
||||
));
|
||||
}
|
||||
}
|
||||
let (left, right) = d.columns(&self.debug_chart_lines());
|
||||
let atlas = &self.gui.atlas;
|
||||
let mut draws = debug_overlay::draw(&atlas.font, [&left, &right], scale, width);
|
||||
let charts = self.debug_charts.draw(
|
||||
&atlas.font,
|
||||
width,
|
||||
height,
|
||||
self.settings.max_fps,
|
||||
1000.0 / self.clock.rate(),
|
||||
!self.has_singleplayer_server(),
|
||||
);
|
||||
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
|
||||
scale, scale,
|
||||
)]));
|
||||
draws.extend(charts);
|
||||
if self.debug_charts.lightmap
|
||||
&& let Some(r) = &self.renderer
|
||||
{
|
||||
draws.extend(horizon_client::debug_chart::lightmap(
|
||||
&r.lightmap_pixels,
|
||||
width,
|
||||
height,
|
||||
));
|
||||
}
|
||||
draws.push(hud_layout::GuiDraw::Pop);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
}
|
||||
/// `KeyMapping.getTranslatedKeyMessage`.
|
||||
fn key_text(&self, mapping: &str) -> Text {
|
||||
@@ -525,13 +570,17 @@ impl Game<'_> {
|
||||
self.debug_feedback(Text::translate(key, &[]));
|
||||
}
|
||||
/// `LocalPlayer.isReducedDebugInfo`: the server's rule or the option.
|
||||
fn reduced_debug_info(&self) -> bool {
|
||||
pub(super) fn reduced_debug_info(&self) -> bool {
|
||||
self.settings.reduced_debug_info || self.ui.rules.bool("reduced_debug_info")
|
||||
}
|
||||
/// The default profile's `3d_crosshair` entry: on while the overlay is
|
||||
/// open, unless debug info is reduced.
|
||||
/// `DebugScreenEntryList.isCurrentlyEnabled` for an entry's path.
|
||||
pub(super) fn debug_entry(&self, path: &str) -> bool {
|
||||
self.debug_entries
|
||||
.enabled(path, self.debug, self.reduced_debug_info())
|
||||
}
|
||||
/// The `3d_crosshair` entry.
|
||||
pub(super) fn debug_crosshair(&self) -> bool {
|
||||
self.debug && !self.reduced_debug_info()
|
||||
self.debug_entry("3d_crosshair")
|
||||
}
|
||||
/// `Permissions.COMMANDS_GAMEMASTER` (`GameModeCommand.PERMISSION_CHECK`).
|
||||
pub(super) fn gamemaster(&self) -> bool {
|
||||
@@ -555,8 +604,10 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
DebugAction::ShowHitboxes if player => {
|
||||
self.show_hitboxes = !self.show_hitboxes;
|
||||
self.debug_translated(if self.show_hitboxes {
|
||||
let on = self
|
||||
.debug_entries
|
||||
.toggle_status("entity_hitboxes", self.debug);
|
||||
self.debug_translated(if on {
|
||||
"debug.show_hitboxes.on"
|
||||
} else {
|
||||
"debug.show_hitboxes.off"
|
||||
@@ -569,8 +620,10 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
DebugAction::ShowChunkBorders if player => {
|
||||
self.show_chunk_borders = !self.show_chunk_borders;
|
||||
self.debug_translated(if self.show_chunk_borders {
|
||||
let on = self
|
||||
.debug_entries
|
||||
.toggle_status("chunk_borders", self.debug);
|
||||
self.debug_translated(if on {
|
||||
"debug.chunk_boundaries.on"
|
||||
} else {
|
||||
"debug.chunk_boundaries.off"
|
||||
@@ -674,11 +727,13 @@ impl Game<'_> {
|
||||
self.debug_charts.toggle_lightmap(&mut self.debug);
|
||||
true
|
||||
}
|
||||
// Screens and overlays Horizon does not draw yet (the debug
|
||||
// options screen, the profiler and its chart, the texture dump):
|
||||
// the keys are used up as in vanilla.
|
||||
DebugAction::DebugOptions
|
||||
| DebugAction::DumpDynamicTextures
|
||||
DebugAction::DebugOptions => {
|
||||
self.toggle_debug_options();
|
||||
true
|
||||
}
|
||||
// Screens and overlays Horizon does not draw yet (the profiler and
|
||||
// its chart, the texture dump): the keys are used up as in vanilla.
|
||||
DebugAction::DumpDynamicTextures
|
||||
| DebugAction::Profiling
|
||||
| DebugAction::ProfilingChart => true,
|
||||
DebugAction::ShowHitboxes
|
||||
@@ -859,7 +914,7 @@ impl Game<'_> {
|
||||
pub(super) fn debug_gizmos(&self, alpha: f32, camera: [f64; 3]) -> Gizmos {
|
||||
let mut g = Gizmos::default();
|
||||
self.bounding_box_gizmos(camera, &mut g);
|
||||
if self.show_chunk_borders
|
||||
if self.debug_entry("chunk_borders")
|
||||
&& let Some(p) = &self.player
|
||||
{
|
||||
let block = [
|
||||
@@ -870,7 +925,7 @@ impl Game<'_> {
|
||||
let min_y = self.world.bottom_y();
|
||||
g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1);
|
||||
}
|
||||
if !self.show_hitboxes {
|
||||
if !self.debug_entry("entity_hitboxes") {
|
||||
return g;
|
||||
}
|
||||
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//! `DebugOptionsScreen` (F3+F6): every debug entry with Off / In Overlay /
|
||||
//! Always buttons, a search box over them, the two built-in profiles and the
|
||||
//! debug overlay's GUI scale. The statuses live in `debug-profile.json`
|
||||
//! (`horizon_client::debug_entries`).
|
||||
//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/debug/DebugOptionsScreen
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
debug_entries::{Category, Entry, Profile, Status, search},
|
||||
hud::HudBatch,
|
||||
options_file::V,
|
||||
text::Text,
|
||||
ui::{self, EditBox, Scroll},
|
||||
};
|
||||
use winit::event::{ElementState, KeyEvent};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
|
||||
/// `HeaderAndFooterLayout(this, 61, 33)`.
|
||||
const HEADER: f32 = 61.0;
|
||||
const FOOTER: f32 = 33.0;
|
||||
/// `OptionList.getRowWidth` and `ITEM_HEIGHT`.
|
||||
const ROW_WIDTH: f32 = 350.0;
|
||||
const ITEM: f32 = 20.0;
|
||||
/// `OptionEntry.BUTTON_WIDTH`, at the CycleButtons' 16 pixels high.
|
||||
const BUTTON: f32 = 60.0;
|
||||
/// The footer: two profile buttons, the GUI scale and Done, 4 pixels apart.
|
||||
const FOOTER_BUTTONS: [f32; 4] = [120.0, 120.0, 110.0, 60.0];
|
||||
/// `debug.options.warning`'s colour (-2142128) and the buttons' text colours.
|
||||
const WARNING: u32 = 0xDF_5050;
|
||||
const UNSELECTED: u32 = 0xBA_BABA;
|
||||
const OVERLAY: u32 = 0xFF_FF55;
|
||||
const NOT_ALLOWED: u32 = 0x80_8080;
|
||||
/// `EditBox.SEARCH_HINT_STYLE`: `ChatFormatting.GRAY`.
|
||||
const HINT: u32 = 0xAA_AAAA;
|
||||
|
||||
/// The open screen: its search box and the list's scroll.
|
||||
pub(super) struct DebugOptionsUi {
|
||||
search: EditBox,
|
||||
scroll: Scroll,
|
||||
}
|
||||
|
||||
impl DebugOptionsUi {
|
||||
pub(super) fn search_focused(&self) -> bool {
|
||||
self.search.focused
|
||||
}
|
||||
}
|
||||
|
||||
/// One list row (`AbstractOptionEntry`).
|
||||
enum Row {
|
||||
Category(Category),
|
||||
Option(&'static Entry),
|
||||
}
|
||||
|
||||
/// `OptionList.updateSearch`: a category row before each category's entries.
|
||||
fn rows(value: &str) -> Vec<Row> {
|
||||
let mut rows = Vec::new();
|
||||
let mut current = None;
|
||||
for entry in search(value) {
|
||||
if current != Some(entry.category) {
|
||||
rows.push(Row::Category(entry.category));
|
||||
current = Some(entry.category);
|
||||
}
|
||||
rows.push(Row::Option(entry));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// `AbstractSelectionList.contentHeight`.
|
||||
fn content_height(n: usize) -> f32 {
|
||||
n as f32 * ITEM + 4.0
|
||||
}
|
||||
/// `getRowLeft`: the list spans the screen, its rows centred.
|
||||
fn row_left(w: f32) -> f32 {
|
||||
(w / 2.0).floor() - ROW_WIDTH / 2.0
|
||||
}
|
||||
/// The three buttons' left edges (Off, In Overlay, Always) for a row whose
|
||||
/// content starts at `x` (`extractContent`).
|
||||
fn button_xs(content_x: f32) -> [f32; 3] {
|
||||
let start = content_x + ROW_WIDTH - 4.0 - 3.0 * BUTTON;
|
||||
[start, start + BUTTON, start + 2.0 * BUTTON]
|
||||
}
|
||||
/// The footer buttons' left edges and their row's top.
|
||||
fn footer(w: f32, h: f32) -> ([f32; 4], f32) {
|
||||
let total: f32 = FOOTER_BUTTONS.iter().sum::<f32>() + 3.0 * 4.0;
|
||||
let mut x = ((w - total) / 2.0).floor();
|
||||
let mut xs = [0.0; 4];
|
||||
for (slot, width) in xs.iter_mut().zip(FOOTER_BUTTONS) {
|
||||
*slot = x;
|
||||
x += width + 4.0;
|
||||
}
|
||||
(xs, h - FOOTER + ((FOOTER - 20.0) / 2.0).floor())
|
||||
}
|
||||
/// The three buttons' statuses and texts, in their order on the row.
|
||||
const BUTTONS: [(Status, &str, u32); 3] = [
|
||||
(Status::Never, "options.off", 0xFF_FFFF),
|
||||
(Status::InOverlay, "debug.entry.overlay", OVERLAY),
|
||||
(Status::AlwaysOn, "debug.entry.always", WARNING),
|
||||
];
|
||||
|
||||
impl Game<'_> {
|
||||
/// `KeyboardHandler`'s F3+F6: closes the screen if it is open, else opens
|
||||
/// it over the game or the pause menu (`canInterruptScreen`).
|
||||
pub(super) fn toggle_debug_options(&mut self) {
|
||||
match self.screen {
|
||||
Screen::DebugOptions => self.close_debug_options(),
|
||||
Screen::Playing | Screen::Paused if !self.screen_over_playing() => {
|
||||
let mut search = EditBox::new(0.0, 0.0, (ROW_WIDTH / 3.0).floor(), 20.0);
|
||||
search.max_length = 32;
|
||||
self.debug_options = Some(DebugOptionsUi {
|
||||
search,
|
||||
scroll: Scroll::default(),
|
||||
});
|
||||
self.screen = Screen::DebugOptions;
|
||||
self.pause_menu = true;
|
||||
self.capture(false);
|
||||
self.mining = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `Screen.onClose`: back to the game.
|
||||
pub(super) fn close_debug_options(&mut self) {
|
||||
self.debug_options = None;
|
||||
self.screen = Screen::Playing;
|
||||
self.capture(true);
|
||||
}
|
||||
/// The header's title row and warning (`init`'s vertical layout,
|
||||
/// centred in the header): the title's x, the row's top, the warning's
|
||||
/// lines with their y.
|
||||
fn debug_options_header(&self, w: f32) -> (f32, f32, Vec<(Text, f32)>) {
|
||||
let font = &self.gui.atlas.font;
|
||||
let title = Text::translate("debug.options.title", &[]);
|
||||
let warning = Text::translate("debug.options.warning", &[]).with_color(WARNING);
|
||||
let lines = font.split(&warning, ROW_WIDTH);
|
||||
let height = 20.0 + 8.0 + lines.len() as f32 * 9.0;
|
||||
let top = ((HEADER - height) / 2.0).floor();
|
||||
let third = (ROW_WIDTH / 3.0).floor();
|
||||
let tw = font.width_text(&title);
|
||||
let row = third + 8.0 + tw + 8.0 + third;
|
||||
let title_x = ((w - row) / 2.0).floor() + third + 8.0;
|
||||
let lines = lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, line)| (line, top + 28.0 + i as f32 * 9.0))
|
||||
.collect();
|
||||
(title_x, top, lines)
|
||||
}
|
||||
/// `debugGuiScale`'s button: `GUI Scale: Unchanged`, `Auto` or the scale.
|
||||
fn debug_gui_scale_label(&self) -> Text {
|
||||
let value = match self.settings.file.int("debugGuiScale") {
|
||||
-1 => Text::translate("options.debugGuiScale.unchanged", &[]),
|
||||
0 => Text::translate("options.guiScale.auto", &[]),
|
||||
n => Text::literal(n.to_string()),
|
||||
};
|
||||
Text::translate(
|
||||
"options.generic_value",
|
||||
&[Text::translate("options.guiScale", &[]), value],
|
||||
)
|
||||
}
|
||||
/// `ClampingLazyMaxIntRange(-1, window.calculateScale(0, unicode))` as a
|
||||
/// CycleButton: the next value, or the previous one with Shift.
|
||||
fn cycle_debug_gui_scale(&mut self, back: bool) {
|
||||
let size = self.renderer.as_ref().map_or([1280, 720], |r| {
|
||||
[r.config.width as i32, r.config.height as i32]
|
||||
});
|
||||
let max = horizon_client::window::calculate_scale(size, 0, self.settings.force_unicode);
|
||||
let current = self.settings.file.int("debugGuiScale").clamp(-1, max);
|
||||
let next = match (back, current) {
|
||||
(false, c) if c >= max => -1,
|
||||
(false, c) => c + 1,
|
||||
(true, -1) => max,
|
||||
(true, c) => c - 1,
|
||||
};
|
||||
self.settings.file.set("debugGuiScale", V::Int(next));
|
||||
self.settings.save(&Self::options_path());
|
||||
}
|
||||
pub(super) fn draw_debug_options(&mut self, batch: &mut HudBatch) {
|
||||
let Some(mut s) = self.debug_options.take() else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
// extractBlurredBackground: the debug overlay goes under the screen.
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map_or([w as u32, h as u32], |r| [r.config.width, r.config.height]);
|
||||
self.draw_debug_overlay_under(batch, size);
|
||||
let atlas = &self.gui.atlas;
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let reduced = self.reduced_debug_info();
|
||||
let rows = rows(&s.search.value);
|
||||
let (top, bottom) = (HEADER, h - FOOTER);
|
||||
let content = content_height(rows.len());
|
||||
s.scroll.clamp(top, bottom, content);
|
||||
// The list: background, rows clipped to it, separators, scrollbar.
|
||||
ui::list_background(batch, atlas, top, bottom, true);
|
||||
let first = batch.vertices.len();
|
||||
let left = row_left(w);
|
||||
let inside = self.cursor.1 >= top && self.cursor.1 < bottom;
|
||||
let mut tooltip = None;
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let y = top + 2.0 - s.scroll.amount.trunc() + i as f32 * ITEM;
|
||||
if y + ITEM < top || y > bottom {
|
||||
continue;
|
||||
}
|
||||
let (cx, cy) = (left + 2.0, y + 2.0);
|
||||
match row {
|
||||
Row::Category(category) => {
|
||||
let label = Text::translate(category.translation_key(), &[])
|
||||
.bold()
|
||||
.underlined();
|
||||
let lw = atlas.font.width_text(&label);
|
||||
let lx = (cx + (ROW_WIDTH - 4.0) / 2.0 - (lw + 8.0) / 2.0).floor() + 4.0;
|
||||
batch.text_styled(atlas, &label, lx, cy + 4.0, u32::MAX, true);
|
||||
}
|
||||
Row::Option(entry) => {
|
||||
let allowed = entry.reduced || !reduced;
|
||||
let name = if allowed {
|
||||
Text::literal(entry.path)
|
||||
} else {
|
||||
Text::literal(entry.path).italic().with_color(NOT_ALLOWED)
|
||||
};
|
||||
batch.text_styled(atlas, &name, cx, cy + 5.0, u32::MAX, true);
|
||||
let xs = button_xs(cx);
|
||||
let status = self.debug_entries.status(entry.path);
|
||||
for ((status_of, key, color), x) in BUTTONS.into_iter().zip(xs) {
|
||||
let selected = status == status_of;
|
||||
let label = Text::translate(key, &[]).with_color(if selected {
|
||||
color
|
||||
} else {
|
||||
UNSELECTED
|
||||
});
|
||||
let hover = inside && ui::hovered(self.cursor, x, cy, BUTTON, 16.0);
|
||||
ui::button(batch, atlas, x, cy, BUTTON, 16.0, &label, !selected, hover);
|
||||
}
|
||||
if !allowed && inside && ui::hovered(self.cursor, cx, y, xs[0] - cx, ITEM) {
|
||||
tooltip = Some(Text::translate("debug.options.notAllowed.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
batch.clip_since(first, 0.0, top, w, bottom);
|
||||
ui::separators(batch, atlas, top, bottom, true);
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
left + ROW_WIDTH + 8.0,
|
||||
top,
|
||||
bottom,
|
||||
content,
|
||||
s.scroll.amount.trunc(),
|
||||
);
|
||||
// The header: title, search box, warning.
|
||||
let (title_x, header_top, lines) = self.debug_options_header(w);
|
||||
let title = Text::translate("debug.options.title", &[]);
|
||||
batch.text_styled(atlas, &title, title_x, header_top + 5.0, u32::MAX, true);
|
||||
s.search.x = title_x + atlas.font.width_text(&title) + 8.0;
|
||||
s.search.y = header_top;
|
||||
s.search.draw(batch, atlas, None);
|
||||
if s.search.value.is_empty() {
|
||||
let hint = Text::translate("debug.options.search", &[])
|
||||
.with_color(HINT)
|
||||
.italic();
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&hint,
|
||||
s.search.text_x(),
|
||||
s.search.text_y(),
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
for (line, y) in &lines {
|
||||
let lw = atlas.font.width_text(line);
|
||||
batch.text_styled(atlas, line, ((w - lw) / 2.0).floor(), *y, u32::MAX, true);
|
||||
}
|
||||
// The footer.
|
||||
let (xs, fy) = footer(w, h);
|
||||
let labels = [
|
||||
Text::translate(Profile::Default.translation_key(), &[]),
|
||||
Text::translate(Profile::Performance.translation_key(), &[]),
|
||||
self.debug_gui_scale_label(),
|
||||
Text::translate("gui.done", &[]),
|
||||
];
|
||||
let active = [
|
||||
!self.debug_entries.is_using_profile(Profile::Default),
|
||||
!self.debug_entries.is_using_profile(Profile::Performance),
|
||||
true,
|
||||
true,
|
||||
];
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let hover = ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0);
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
xs[i],
|
||||
fy,
|
||||
FOOTER_BUTTONS[i],
|
||||
20.0,
|
||||
label,
|
||||
active[i],
|
||||
hover,
|
||||
);
|
||||
if i == 2 && hover {
|
||||
tooltip = Some(Text::translate("options.debugGuiScale.tooltip", &[]));
|
||||
}
|
||||
}
|
||||
if let Some(tooltip) = tooltip {
|
||||
let lines = atlas.font.split(&tooltip, 200.0);
|
||||
ui::tooltip(batch, atlas, &lines, self.cursor);
|
||||
}
|
||||
self.debug_options = Some(s);
|
||||
}
|
||||
/// A left click: a status button, the search box, a footer button.
|
||||
pub(super) fn debug_options_click(&mut self) {
|
||||
let Some(mut s) = self.debug_options.take() else {
|
||||
return;
|
||||
};
|
||||
let (w, h) = self.gui_size();
|
||||
let (top, bottom) = (HEADER, h - FOOTER);
|
||||
let shift =
|
||||
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
|
||||
s.search.focused = ui::hovered(
|
||||
self.cursor,
|
||||
s.search.x,
|
||||
s.search.y,
|
||||
s.search.width,
|
||||
s.search.height,
|
||||
);
|
||||
if s.search.focused {
|
||||
s.search.click(&self.gui.atlas, self.cursor.0, shift, false);
|
||||
}
|
||||
let inside = self.cursor.1 >= top && self.cursor.1 < bottom;
|
||||
let rows = rows(&s.search.value);
|
||||
let left = row_left(w);
|
||||
let mut clicked = None;
|
||||
if inside {
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let Row::Option(entry) = row else { continue };
|
||||
let cy = top + 2.0 - s.scroll.amount.trunc() + i as f32 * ITEM + 2.0;
|
||||
let status = self.debug_entries.status(entry.path);
|
||||
for ((status_of, _, _), x) in BUTTONS.into_iter().zip(button_xs(left + 2.0)) {
|
||||
if status != status_of && ui::hovered(self.cursor, x, cy, BUTTON, 16.0) {
|
||||
clicked = Some((entry.path, status_of));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.debug_options = Some(s);
|
||||
if let Some((path, status)) = clicked {
|
||||
// OptionEntry.setValue.
|
||||
self.audio.play_button_click();
|
||||
self.debug_entries.set_status(path, status);
|
||||
return;
|
||||
}
|
||||
let (xs, fy) = footer(w, h);
|
||||
let Some(i) =
|
||||
(0..4).find(|&i| ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match i {
|
||||
0 | 1 => {
|
||||
let profile = if i == 0 {
|
||||
Profile::Default
|
||||
} else {
|
||||
Profile::Performance
|
||||
};
|
||||
if self.debug_entries.is_using_profile(profile) {
|
||||
return;
|
||||
}
|
||||
self.audio.play_button_click();
|
||||
self.debug_entries.load_profile(profile);
|
||||
}
|
||||
2 => {
|
||||
self.audio.play_button_click();
|
||||
self.cycle_debug_gui_scale(shift);
|
||||
}
|
||||
_ => {
|
||||
self.audio.play_button_click();
|
||||
self.close_debug_options();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The mouse wheel over the list (`scrollRate`: half a row).
|
||||
pub(super) fn debug_options_scroll(&mut self, delta: f32) {
|
||||
let (_, h) = self.gui_size();
|
||||
if let Some(s) = &mut self.debug_options {
|
||||
let content = content_height(rows(&s.search.value).len());
|
||||
s.scroll
|
||||
.wheel(delta, ITEM / 2.0, HEADER, h - FOOTER, content);
|
||||
}
|
||||
}
|
||||
/// A key the debug keys left: Escape closes, the rest edits the search.
|
||||
pub(super) fn debug_options_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
}
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
if key == KeyCode::Escape {
|
||||
self.close_debug_options();
|
||||
return;
|
||||
}
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let types_text = self.types_text();
|
||||
let Some(s) = &mut self.debug_options else {
|
||||
return;
|
||||
};
|
||||
if !s.search.focused {
|
||||
return;
|
||||
}
|
||||
if let Some(shortcut) = shortcut {
|
||||
s.search.shortcut(shortcut, &mut self.clipboard);
|
||||
} else if !s.search.key(key, ctrl, shift)
|
||||
&& types_text
|
||||
&& let Some(text) = event.text.as_deref()
|
||||
{
|
||||
self.debug_options_type(text);
|
||||
}
|
||||
}
|
||||
/// Characters typed into the search box (`EditBox.charTyped`).
|
||||
pub(super) fn debug_options_type(&mut self, typed: &str) {
|
||||
let allowed: String = typed
|
||||
.chars()
|
||||
.filter(|&c| c != '\u{a7}' && c >= ' ' && c != '\u{7f}')
|
||||
.collect();
|
||||
if let Some(s) = &mut self.debug_options
|
||||
&& s.search.focused
|
||||
&& !allowed.is_empty()
|
||||
{
|
||||
s.search.insert_text(&allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rows_put_a_category_before_each_section() {
|
||||
let all = rows("");
|
||||
assert_eq!(all.len(), 48);
|
||||
assert!(matches!(all[0], Row::Category(Category::Text)));
|
||||
let renderer = all
|
||||
.iter()
|
||||
.position(|r| matches!(r, Row::Category(Category::Renderer)))
|
||||
.unwrap();
|
||||
assert!(matches!(all[renderer + 1], Row::Option(e) if e.path == "3d_crosshair"));
|
||||
// A search inside one section shows only its heading.
|
||||
let hitboxes = rows("hitbox");
|
||||
assert_eq!(hitboxes.len(), 2);
|
||||
assert!(matches!(hitboxes[0], Row::Category(Category::Renderer)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layout_matches_vanilla_at_854_by_480() {
|
||||
// 854x480 at GUI scale 2 is 427x240.
|
||||
assert_eq!(row_left(427.0), 38.0);
|
||||
assert_eq!(button_xs(40.0), [206.0, 266.0, 326.0]);
|
||||
assert_eq!(footer(427.0, 240.0), ([2.0, 126.0, 250.0, 364.0], 213.0));
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,8 @@ impl Game<'_> {
|
||||
);
|
||||
self.block_entity_event(pos, 1, WOBBLE_NEGATIVE);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
// ServerPlayerGameMode.useItemOn: useWithoutItem consumed the click.
|
||||
self.trigger_default_block_use(pos);
|
||||
return true;
|
||||
};
|
||||
let stack = stack.clone();
|
||||
|
||||
@@ -34,6 +34,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub(super) mod events;
|
||||
mod report;
|
||||
mod status;
|
||||
|
||||
@@ -165,8 +166,8 @@ pub(super) fn player_joined(id: u128, name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; off
|
||||
/// without the file or the section, or (with an error logged) when the section is wrong.
|
||||
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; the
|
||||
/// default (on) without the file or the section, off (with an error logged) when the section is wrong.
|
||||
fn load_anticheat() -> horizon_anticheat::Config {
|
||||
let Ok(text) = std::fs::read_to_string("server.toml") else {
|
||||
return Default::default();
|
||||
@@ -174,7 +175,10 @@ fn load_anticheat() -> horizon_anticheat::Config {
|
||||
match horizon_anticheat::Config::from_server_toml(&text) {
|
||||
Ok(config) => {
|
||||
if config.enabled {
|
||||
log("INFO", "Anticheat on (server.toml [anticheat])");
|
||||
log(
|
||||
"INFO",
|
||||
"Anticheat on (server.toml [anticheat]; enabled = false turns it off)",
|
||||
);
|
||||
}
|
||||
config
|
||||
}
|
||||
@@ -183,7 +187,10 @@ fn load_anticheat() -> horizon_anticheat::Config {
|
||||
"ERROR",
|
||||
&format!("server.toml: {e}; the anticheat stays off"),
|
||||
);
|
||||
Default::default()
|
||||
horizon_anticheat::Config {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -697,6 +704,8 @@ impl Game<'_> {
|
||||
self.server_lists = Some(lists.clone());
|
||||
log("INFO", &format!("Preparing level \"{level_name}\""));
|
||||
let folder = world_dir.to_string_lossy().into_owned();
|
||||
// MinecraftServer.loadLevel's start: nothing is loaded yet.
|
||||
self.native_server_about_to_start(true);
|
||||
self.native_lifecycle_open_started();
|
||||
// The registries the server sends are read while the world prepares.
|
||||
let sync = super::lan::preload_sync_data(&self.options.resources);
|
||||
@@ -1032,11 +1041,12 @@ impl Game<'_> {
|
||||
/// nobody is online a dedicated server keeps none (vanilla has no spawn
|
||||
/// ticket), an integrated one the spawn chunk's.
|
||||
pub(super) fn update_dedicated_streaming(&mut self) {
|
||||
let rule = self.ui.rules.bool("spectators_generate_chunks");
|
||||
let guest = self.lan.as_ref().and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| h.hub.here(g))
|
||||
.find(|g| h.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator()))
|
||||
.map(|g| g.chunk())
|
||||
});
|
||||
if self.standalone_server() {
|
||||
@@ -1636,6 +1646,14 @@ impl Game<'_> {
|
||||
.collect();
|
||||
for conn in conns {
|
||||
h.hub.kick(conn, reason.clone());
|
||||
if let Some(g) = h.hub.guest(conn) {
|
||||
events::record(
|
||||
"kick",
|
||||
&g.name,
|
||||
uuid,
|
||||
serde_json::json!({ "reason": g.left_reason }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1662,6 +1680,8 @@ impl Game<'_> {
|
||||
fn stop_dedicated(&mut self) -> Result<()> {
|
||||
// The close request starts before player and world saves.
|
||||
self.native_lifecycle.begin_close();
|
||||
// MinecraftServer.stopServer's start: everyone is still here.
|
||||
self.native_server_stopping(true);
|
||||
log("INFO", "Stopping server");
|
||||
log("INFO", "Saving players");
|
||||
if let Some(host) = self.lan.take() {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! What happened on the dedicated server, typed, for horizon-server's event stream (`GET /api/v1/events`,
|
||||
//! docs/api/server.md): players joining, leaving and kicked, chat, and deaths. The log says the same in
|
||||
//! vanilla's words; these carry the player's UUID and the parts apart, so a bot needn't parse lines.
|
||||
//! `server-events SINCE` on the control endpoint answers the events after the SINCE-th.
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::VecDeque, sync::Mutex};
|
||||
|
||||
/// The events kept for a reader that polls late.
|
||||
const KEPT: usize = 1000;
|
||||
|
||||
struct Events {
|
||||
/// The number of the next event (the first is 1).
|
||||
next: u64,
|
||||
kept: VecDeque<Value>,
|
||||
}
|
||||
|
||||
static EVENTS: Mutex<Events> = Mutex::new(Events {
|
||||
next: 1,
|
||||
kept: VecDeque::new(),
|
||||
});
|
||||
|
||||
/// Records one event: its kind, the player's name and UUID, and what else it says.
|
||||
pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Value) {
|
||||
let mut events = EVENTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
fields["seq"] = events.next.into();
|
||||
fields["type"] = kind.into();
|
||||
fields["name"] = name.into();
|
||||
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
|
||||
fields["time"] = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64)
|
||||
.into();
|
||||
events.next += 1;
|
||||
if events.kept.len() == KEPT {
|
||||
events.kept.pop_front();
|
||||
}
|
||||
events.kept.push_back(fields);
|
||||
}
|
||||
|
||||
/// `server-events SINCE`: the events numbered above `since`, and the number to ask from next.
|
||||
pub(super) fn since(since: u64) -> Value {
|
||||
let events = EVENTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let after: Vec<&Value> = events
|
||||
.kept
|
||||
.iter()
|
||||
.filter(|e| e["seq"].as_u64().is_some_and(|s| s > since))
|
||||
.collect();
|
||||
json!({ "next": events.next - 1, "events": after })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn events_are_numbered_and_read_from_a_number() {
|
||||
let first = since(u64::MAX)["next"].as_u64().unwrap();
|
||||
record("join", "Kim0", 1, json!({}));
|
||||
record("chat", "Kim0", 1, json!({ "message": "hi" }));
|
||||
let read = since(first);
|
||||
let events = read["events"].as_array().unwrap();
|
||||
assert_eq!(read["next"].as_u64(), Some(first + 2));
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[1]["type"], "chat");
|
||||
assert_eq!(events[1]["message"], "hi");
|
||||
assert_eq!(events[1]["uuid"], "00000000-0000-0000-0000-000000000001");
|
||||
assert_eq!(since(first + 1)["events"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! What `horizon-server` asks the dedicated server on its control endpoint (`--control-socket`, docs/server.md
|
||||
//! section 2): `server-status` answers with the tick rate, the tick times `/tick query` reports, the players
|
||||
//! and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged.
|
||||
//! and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
|
||||
//! `server-events <since>` answers the typed events after that number (events.rs).
|
||||
use super::{ServerState, capture_log};
|
||||
use crate::game::Game;
|
||||
use serde_json::{Value, json};
|
||||
@@ -41,6 +42,9 @@ impl Game<'_> {
|
||||
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
|
||||
let reply = match request.line.trim().split_once(' ') {
|
||||
_ if request.line.trim() == "server-status" => self.server_status(state),
|
||||
Some(("server-events", since)) => {
|
||||
super::events::since(since.trim().parse().unwrap_or(0))
|
||||
}
|
||||
Some(("console", line)) => {
|
||||
// Command feedback is logged as system chat; the reply carries the text alone.
|
||||
let output: Vec<String> = capture_log(|| self.console_command(state, line))
|
||||
|
||||
@@ -1812,7 +1812,7 @@ impl Game<'_> {
|
||||
f64::from(r.next_float() * 0.05),
|
||||
f64::from((r.next_float() - r.next_float()) * 0.1),
|
||||
];
|
||||
if let Some(item) = self.dropped.last_mut() {
|
||||
if let Some(item) = self.just_spawned_item() {
|
||||
item.velocity = Vec3::new(
|
||||
item.velocity.x + push[0],
|
||||
item.velocity.y + push[1],
|
||||
|
||||
@@ -122,14 +122,6 @@ impl Game<'_> {
|
||||
base,
|
||||
)
|
||||
}
|
||||
/// Strength / weakness on the attack damage attribute.
|
||||
pub(super) fn effect_attack_bonus(&self) -> f32 {
|
||||
let fx = &self.potions.effects;
|
||||
fx.amplifier("strength")
|
||||
.map_or(0.0, |a| 3.0 * (a + 1) as f32)
|
||||
- fx.amplifier("weakness")
|
||||
.map_or(0.0, |a| 4.0 * (a + 1) as f32)
|
||||
}
|
||||
/// `Player.attack`'s extra knockback: `getKnockback` (the weapon's knockback
|
||||
/// enchantment, halved) plus 0.5 for a sprinting full-strength hit, and
|
||||
/// whether the hit is that knockback attack.
|
||||
|
||||
@@ -1682,8 +1682,9 @@ impl Game<'_> {
|
||||
// setPortalCooldown before the teleport.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
// ServerLevel.addDuringTeleport: a fresh add, not loaded from disk.
|
||||
super::persist::mark_fresh(&mut tag);
|
||||
// A gateway keeps the entity in its level (teleportSameDimension
|
||||
// moves it, no addDuringTeleport): it doesn't join again.
|
||||
super::persist::mark_moved(&mut tag);
|
||||
match self
|
||||
.world
|
||||
.loaded_entities
|
||||
|
||||
@@ -18,6 +18,13 @@ impl ExecutionRole {
|
||||
pub fn starts_integrated_server(self, server_thread: bool) -> bool {
|
||||
self.is_client() && server_thread
|
||||
}
|
||||
|
||||
/// Single-player worlds (in-frame or on the integrated server thread) keep
|
||||
/// the owner's state in `Level` and write `players/data/<singleplayer_uuid>.dat`
|
||||
/// with the world; a dedicated server saves each connected player separately.
|
||||
pub fn writes_singleplayer_player(self) -> bool {
|
||||
self != Self::DedicatedServer
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -26,14 +33,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn world_loading_keeps_in_frame_clients_and_server_roles_separate() {
|
||||
for (role, client, threaded) in [
|
||||
(Client, true, true),
|
||||
(IntegratedServer, false, false),
|
||||
(DedicatedServer, false, false),
|
||||
for (role, client, threaded, writes_player) in [
|
||||
(Client, true, true, true),
|
||||
(IntegratedServer, false, false, true),
|
||||
(DedicatedServer, false, false, false),
|
||||
] {
|
||||
assert_eq!(role.is_client(), client);
|
||||
assert_eq!(role.starts_integrated_server(true), threaded);
|
||||
assert!(!role.starts_integrated_server(false));
|
||||
assert_eq!(role.writes_singleplayer_player(), writes_player);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
//! draws), entity damage with exposure and knockback, block interaction with
|
||||
//! merged drops and TNT chain priming, and optional fire.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack};
|
||||
use horizon_client::{
|
||||
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Vec3, VoxelShape},
|
||||
position::BlockPos,
|
||||
@@ -136,6 +138,30 @@ fn hurt_and_push_mob(
|
||||
m.needs_sync = true;
|
||||
}
|
||||
|
||||
/// A blast's causing entity and its name (`Explosion.getIndirectSourceEntity`):
|
||||
/// the mob that owns the exploding projectile while it is in the level
|
||||
/// (`Projectile.getOwner` is null once it is removed), else the exploding
|
||||
/// mob. `live` names a mob in the level, `exploded` the mobs whose blast
|
||||
/// went off this tick and that already left it.
|
||||
fn blast_cause(
|
||||
owner: Option<u32>,
|
||||
exploding: Option<u32>,
|
||||
live: impl Fn(u32) -> Option<Text>,
|
||||
exploded: &[(u32, Text)],
|
||||
) -> Option<(u32, Text)> {
|
||||
if let Some(owner) = owner {
|
||||
return live(owner).map(|name| (owner, name));
|
||||
}
|
||||
let id = exploding?;
|
||||
let name = live(id).or_else(|| {
|
||||
exploded
|
||||
.iter()
|
||||
.rfind(|(m, _)| *m == id)
|
||||
.map(|(_, n)| n.clone())
|
||||
})?;
|
||||
Some((id, name))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Client-side randomness (explosion sound pitch), separate from the level random.
|
||||
static CLIENT_RANDOM: std::cell::RefCell<LegacyRandom> = std::cell::RefCell::new(LegacyRandom::new(0x5eed));
|
||||
@@ -145,6 +171,15 @@ pub(super) fn client_float() -> f32 {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `blast_cause` for the blast going off now.
|
||||
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
|
||||
blast_cause(
|
||||
self.combat.explosion_owner,
|
||||
self.combat.explosion_source,
|
||||
|id| self.mob_display_name(id),
|
||||
&self.combat.exploded,
|
||||
)
|
||||
}
|
||||
/// `ServerExplosion.getSeenPercent` for an entity box.
|
||||
pub(super) fn seen_percent(&self, center: [f64; 3], min: [f64; 3], max: [f64; 3]) -> f32 {
|
||||
let xs = 1.0 / ((max[0] - min[0]) * 2.0 + 1.0);
|
||||
@@ -249,16 +284,26 @@ impl Game<'_> {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// A primed TNT minecart's rails and the blocks they
|
||||
// stand on: no resistance, and never blown up.
|
||||
let spared = self.combat.rail_proof_blast
|
||||
&& (self.rail(pos[0], pos[1], pos[2]).is_some()
|
||||
|| self.rail(pos[0], pos[1] + 1, pos[2]).is_some());
|
||||
match resists {
|
||||
Some((at, resistance)) if at == pos => {
|
||||
power -= (resistance + 0.3) * 0.3;
|
||||
}
|
||||
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
|
||||
power -= (f.explosion_resistance.max(fluid) + 0.3) * 0.3;
|
||||
let resistance = if spared {
|
||||
0.0
|
||||
} else {
|
||||
f.explosion_resistance.max(fluid)
|
||||
};
|
||||
power -= (resistance + 0.3) * 0.3;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if power > 0.0 {
|
||||
if power > 0.0 && !spared {
|
||||
set.put(Key(pos), ());
|
||||
}
|
||||
xp += xd * f64::from(0.3f32);
|
||||
@@ -288,6 +333,7 @@ impl Game<'_> {
|
||||
radius: f32,
|
||||
damage_type: &str,
|
||||
exploding: Option<u32>,
|
||||
attacker: Option<&(u32, Text)>,
|
||||
) -> Option<[f64; 3]> {
|
||||
let diameter = f64::from(radius * 2.0);
|
||||
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
|
||||
@@ -341,6 +387,11 @@ impl Game<'_> {
|
||||
let flying = self.player.as_ref().is_some_and(|p| p.flying);
|
||||
let mut outcome = horizon_client::health::BlockOutcome::default();
|
||||
let mut pushed = None;
|
||||
// The death message names the blast's causing entity. It goes to this
|
||||
// player's tracker, the host's or a guest's.
|
||||
if let Some((id, name)) = attacker.filter(|_| !creative && !spectator && hurtable) {
|
||||
self.note_named_attacker(damage_type, *id, name.clone());
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(damage_type)
|
||||
{
|
||||
@@ -354,8 +405,7 @@ impl Game<'_> {
|
||||
id,
|
||||
amount,
|
||||
blocking,
|
||||
// The exploding mob is the causing entity.
|
||||
exploding.is_some(),
|
||||
attacker.is_some(),
|
||||
)
|
||||
{
|
||||
outcome = o;
|
||||
@@ -429,7 +479,9 @@ impl Game<'_> {
|
||||
// The player, then every other player in the level (LAN guests),
|
||||
// each swapped in as the player. In a guest's turn (their hit on an
|
||||
// end crystal) the player is that guest.
|
||||
let own = self.explosion_hurt_player(center, radius, damage_type, exploding);
|
||||
let attacker = self.blast_attacker();
|
||||
let own =
|
||||
self.explosion_hurt_player(center, radius, damage_type, exploding, attacker.as_ref());
|
||||
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
|
||||
if let Some(mut host) = self.lan.take() {
|
||||
let guests: Vec<u32> = host
|
||||
@@ -441,7 +493,13 @@ impl Game<'_> {
|
||||
.collect();
|
||||
for conn in guests {
|
||||
let push = self.as_guest(&mut host, conn, |g| {
|
||||
g.explosion_hurt_player(center, radius, damage_type, exploding)
|
||||
g.explosion_hurt_player(
|
||||
center,
|
||||
radius,
|
||||
damage_type,
|
||||
exploding,
|
||||
attacker.as_ref(),
|
||||
)
|
||||
});
|
||||
if let Some(Some(push)) = push {
|
||||
pushed.push((conn, push));
|
||||
@@ -486,10 +544,9 @@ impl Game<'_> {
|
||||
kind: horizon_client::mobs::DamageKind::Explosion,
|
||||
// A projectile's owner (`getIndirectSourceEntity`), else
|
||||
// the exploding mob itself.
|
||||
attacker: self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(exploding)
|
||||
attacker: attacker
|
||||
.as_ref()
|
||||
.map(|&(id, _)| id)
|
||||
.filter(|&o| o != m.id)
|
||||
.map(horizon_client::mobs::Who::Mob),
|
||||
from: Some(center),
|
||||
@@ -592,14 +649,10 @@ impl Game<'_> {
|
||||
self.explosion_breaks_decorations(center, diameter, interaction);
|
||||
pushed
|
||||
}
|
||||
/// `blast_spares_decorations` for the blast going off now: the mob that
|
||||
/// owns its projectile or explodes itself is the source's entity.
|
||||
/// `blast_spares_decorations` for the blast going off now: its causing
|
||||
/// mob (`blast_attacker`) is the source's entity.
|
||||
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
|
||||
let by_mob = self
|
||||
.combat
|
||||
.explosion_owner
|
||||
.or(self.combat.explosion_source)
|
||||
.is_some();
|
||||
let by_mob = self.blast_attacker().is_some();
|
||||
blast_spares_decorations(self.ui.rules.bool("mob_griefing"), interaction, by_mob)
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` for paintings, cushions and armour
|
||||
@@ -681,14 +734,11 @@ impl Game<'_> {
|
||||
interaction: BlockInteraction,
|
||||
fire: bool,
|
||||
) -> Result<()> {
|
||||
self.server_explode_as(
|
||||
center,
|
||||
radius,
|
||||
interaction,
|
||||
fire,
|
||||
"minecraft:explosion",
|
||||
None,
|
||||
)
|
||||
let damage_type = super::creatures::damage_type(
|
||||
horizon_client::mobs::DamageKind::Explosion,
|
||||
self.blast_attacker().is_some(),
|
||||
);
|
||||
self.server_explode_as(center, radius, interaction, fire, damage_type, None)
|
||||
}
|
||||
/// `server_explode` with the damage type the player takes and a block
|
||||
/// whose explosion resistance the damage calculator overrides.
|
||||
@@ -1003,6 +1053,69 @@ mod tests {
|
||||
assert!(blast_spares_decorations(false, Destroy, true));
|
||||
}
|
||||
|
||||
/// The type a blast hurts with and the attacker its death message names
|
||||
/// come from one cause (`DamageSources.explosion`): `player_explosion`
|
||||
/// "blown up by" that mob, or `explosion` with no one when it has none.
|
||||
fn blast_hurt(
|
||||
owner: Option<u32>,
|
||||
exploding: Option<u32>,
|
||||
live: &[(u32, Text)],
|
||||
exploded: &[(u32, Text)],
|
||||
) -> (&'static str, Option<(u32, Text)>) {
|
||||
let find = |id| live.iter().find(|(m, _)| *m == id).map(|(_, n)| n.clone());
|
||||
let cause = blast_cause(owner, exploding, find, exploded);
|
||||
let kind = horizon_client::mobs::DamageKind::Explosion;
|
||||
(
|
||||
super::super::creatures::damage_type(kind, cause.is_some()),
|
||||
cause,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caused_blast_names_its_cause_under_player_explosion() {
|
||||
let creeper = Text::translate("entity.minecraft.creeper", &[]);
|
||||
let ghast = Text::translate("entity.minecraft.ghast", &[]);
|
||||
// A creeper's blast: it left the mob list, its event named it.
|
||||
assert_eq!(
|
||||
blast_hurt(None, Some(7), &[], &[(7, creeper.clone())]),
|
||||
("minecraft:player_explosion", Some((7, creeper)))
|
||||
);
|
||||
// A ghast's fireball: its owner is still in the level.
|
||||
assert_eq!(
|
||||
blast_hurt(Some(3), None, &[(3, ghast.clone())], &[]),
|
||||
("minecraft:player_explosion", Some((3, ghast.clone())))
|
||||
);
|
||||
// The ghast was removed before its fireball landed: no owner.
|
||||
assert_eq!(
|
||||
blast_hurt(Some(3), None, &[], &[]),
|
||||
("minecraft:explosion", None)
|
||||
);
|
||||
// Nothing caused it.
|
||||
assert_eq!(
|
||||
blast_hurt(None, None, &[], &[]),
|
||||
("minecraft:explosion", None)
|
||||
);
|
||||
// A projectile's owner wins over the exploding mob, and a removed
|
||||
// owner gives no cause even when a mob is exploding.
|
||||
let zombie = Text::translate("entity.minecraft.zombie", &[]);
|
||||
let live = [(3, ghast.clone()), (5, zombie.clone())];
|
||||
assert_eq!(
|
||||
blast_hurt(Some(3), Some(5), &live, &[]),
|
||||
("minecraft:player_explosion", Some((3, ghast)))
|
||||
);
|
||||
assert_eq!(
|
||||
blast_hurt(Some(4), Some(5), &live, &[]),
|
||||
("minecraft:explosion", None)
|
||||
);
|
||||
// A mob still in the level is named as it is now, before its name
|
||||
// in the exploded list.
|
||||
let named = Text::literal("Bob".to_owned());
|
||||
assert_eq!(
|
||||
blast_hurt(None, Some(5), &live, &[(5, named)]),
|
||||
("minecraft:player_explosion", Some((5, zombie)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_pos_hash_matches_vec3i() {
|
||||
// Vec3i.hashCode: (y + z * 31) * 31 + x
|
||||
|
||||
@@ -76,8 +76,8 @@ impl Game<'_> {
|
||||
(cps, self.extras.mouse)
|
||||
}
|
||||
/// Auto-reconnect: a connection to `address` as `name` starts.
|
||||
pub(super) fn extras_connecting(&mut self, address: &str, name: &str) {
|
||||
self.extras.reconnect.connecting(address, name);
|
||||
pub(super) fn extras_connecting(&mut self, address: &str, name: &str, lan: bool) {
|
||||
self.extras.reconnect.connecting(address, name, lan);
|
||||
}
|
||||
/// The server this game connected to last.
|
||||
pub(super) fn server_address(&self) -> Option<&str> {
|
||||
@@ -124,7 +124,7 @@ impl Game<'_> {
|
||||
/// (the launcher's session file is gone after the first read).
|
||||
pub(super) fn extras_retry(&mut self) -> horizon_client::Result<()> {
|
||||
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
|
||||
self.connect_server(&retry.address, &retry.name)?;
|
||||
self.connect_server_with_options(&retry.address, &retry.name, None, retry.lan)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ use horizon_client::{
|
||||
entity_nbt::RocketNbt,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
mobs::{Damage, DamageKind, Species, Who},
|
||||
mobs::{Damage, Species, Who},
|
||||
particles::{FireworkExplosion, ParticleOptions, ParticleType},
|
||||
projectiles::ProjectileWorld,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
@@ -398,14 +399,14 @@ impl Game<'_> {
|
||||
// other player in range and in sight takes the blast.
|
||||
let boosted = r.guest.filter(|_| r.attached);
|
||||
if r.attached && boosted.is_none() {
|
||||
self.rocket_hurt_player(damage);
|
||||
self.rocket_hurt_player(from, damage);
|
||||
} else {
|
||||
self.rocket_blast_player(from, damage);
|
||||
}
|
||||
if let Some(conn) = boosted
|
||||
&& let Some(mut host) = self.lan.take()
|
||||
{
|
||||
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(damage));
|
||||
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(from, damage));
|
||||
self.lan = Some(host);
|
||||
}
|
||||
// Every other player in the level (LAN guests, the players of a
|
||||
@@ -466,14 +467,7 @@ impl Game<'_> {
|
||||
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
|
||||
for &(id, amount) in hits {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.damage(
|
||||
amount,
|
||||
Damage {
|
||||
kind: DamageKind::Explosion,
|
||||
attacker,
|
||||
from: Some(at),
|
||||
},
|
||||
);
|
||||
m.damage(amount, Damage::fireworks(attacker, at));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,21 +571,30 @@ impl Game<'_> {
|
||||
})
|
||||
};
|
||||
if sees {
|
||||
self.rocket_hurt_player(blast_damage(damage, from, feet));
|
||||
self.rocket_hurt_player(from, blast_damage(damage, from, feet));
|
||||
}
|
||||
}
|
||||
/// `hurtServer` with `damageSources().fireworks`: armor absorbs it and
|
||||
/// wears (`hurtArmor`); creative and spectator players are immune.
|
||||
fn rocket_hurt_player(&mut self, amount: f32) {
|
||||
if self.creative || self.spectator() {
|
||||
/// `hurtServer` with `damageSources().fireworks` from the rocket at
|
||||
/// `from`: the rocket is the direct entity, so the player is knocked away
|
||||
/// from it and the death message names it; creative and spectator
|
||||
/// players are immune.
|
||||
fn rocket_hurt_player(&mut self, from: Vec3, amount: f32) {
|
||||
if self.creative
|
||||
|| self.spectator()
|
||||
|| self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_none_or(|p| p.health.dead_or_dying())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !p.health.dead_or_dying()
|
||||
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
|
||||
{
|
||||
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
|
||||
}
|
||||
// No mob ever owns a rocket.
|
||||
self.combat.projectile_owner = None;
|
||||
self.combat.rocket_hit = true;
|
||||
self.combat.projectile_direct =
|
||||
Some(Text::translate("entity.minecraft.firework_rocket", &[]));
|
||||
let at = [from.x, from.y, from.z];
|
||||
self.hurt_player_from(amount, Some(at), "minecraft:fireworks", Some(None));
|
||||
}
|
||||
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
|
||||
/// lit at the rocket's light probe.
|
||||
|
||||
@@ -283,6 +283,12 @@ impl Game<'_> {
|
||||
}
|
||||
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
|
||||
fn drop_frame_items(&mut self, f: &ItemFrame, with_frame: bool, by_player: bool) {
|
||||
// The framed map forgets the frame, except when the drops are off and
|
||||
// something caused the break (`removeFramedMap` runs only for a null
|
||||
// `causedBy` then).
|
||||
if self.ui.rules.bool("entity_drops") || !by_player {
|
||||
self.remove_framed_map(f);
|
||||
}
|
||||
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Game modes beyond survival and creative (`GameType`): adventure players
|
||||
//! cannot build, spectators fly through blocks and only watch.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::command::args::GameMode;
|
||||
use horizon_client::{collisions::SceneCollisions, command::args::GameMode};
|
||||
use horizon_world::world_movement::in_range_of_ground;
|
||||
|
||||
pub(super) fn mode_id_of(mode: GameMode) -> i32 {
|
||||
match mode {
|
||||
@@ -19,6 +20,14 @@ pub(super) fn mode_from_id(id: i32) -> GameMode {
|
||||
_ => GameMode::Survival,
|
||||
}
|
||||
}
|
||||
/// `ChunkMap.skipPlayer`: a player is skipped when `player.isSpectator() &&
|
||||
/// !level.getGameRules().getBoolean(SPECTATORS_GENERATE_CHUNKS)`. A skipped
|
||||
/// player holds no distance-manager ticket and streams no chunks around
|
||||
/// itself, but still receives the chunks other players load (chunk sending
|
||||
/// is untouched).
|
||||
pub(super) fn loads_chunks(spectators_generate_chunks: bool, spectator: bool) -> bool {
|
||||
!spectator || spectators_generate_chunks
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn spectator(&self) -> bool {
|
||||
self.mode == GameMode::Spectator
|
||||
@@ -36,8 +45,9 @@ impl Game<'_> {
|
||||
/// `ServerPlayer.setGameMode` and `GameType.updatePlayerAbilities`.
|
||||
pub(super) fn set_mode(&mut self, mode: GameMode) {
|
||||
let was_spectator = self.spectator();
|
||||
let changed = mode != self.mode;
|
||||
// MultiPlayerGameMode.setLocalMode: the mode it leaves.
|
||||
if mode != self.mode {
|
||||
if changed {
|
||||
self.previous_mode = Some(self.mode);
|
||||
}
|
||||
self.mode = mode;
|
||||
@@ -67,6 +77,28 @@ impl Game<'_> {
|
||||
p.flying = true;
|
||||
}
|
||||
}
|
||||
// ServerPlayerGameMode.changeGameModeForPlayer: a player left flying
|
||||
// by the change lands when the ground is within a block.
|
||||
let flying = self
|
||||
.player
|
||||
.as_ref()
|
||||
.filter(|p| p.flying)
|
||||
.map(|p| p.bounds());
|
||||
if changed
|
||||
&& mode != GameMode::Spectator
|
||||
&& let Some(bounds) = flying
|
||||
{
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
if in_range_of_ground(&mut scene, bounds)
|
||||
&& let Some(p) = self.player.as_mut()
|
||||
{
|
||||
p.flying = false;
|
||||
}
|
||||
}
|
||||
if mode == GameMode::Spectator && self.screen == Screen::Inventory {
|
||||
self.screen = Screen::Playing;
|
||||
}
|
||||
@@ -186,3 +218,24 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::loads_chunks;
|
||||
|
||||
/// `ChunkMap.skipPlayer`: only a spectator with `spectators_generate_chunks`
|
||||
/// off stops loading chunks around itself.
|
||||
#[test]
|
||||
fn a_skipped_spectator_loads_no_chunks() {
|
||||
assert!(!loads_chunks(false, true), "spectator, rule off");
|
||||
assert!(loads_chunks(true, true), "spectator, rule on");
|
||||
assert!(
|
||||
loads_chunks(false, false),
|
||||
"survival, creative or adventure"
|
||||
);
|
||||
assert!(
|
||||
loads_chunks(true, false),
|
||||
"survival, creative or adventure, rule on"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -333,7 +333,7 @@ impl Game<'_> {
|
||||
|| before(self.redirectable_hit().map(|h| h.1))
|
||||
}
|
||||
/// `LocalPlayer.isHandsBusy`: paddling a boat.
|
||||
fn hands_busy(&self) -> bool {
|
||||
pub(super) fn hands_busy(&self) -> bool {
|
||||
self.riding
|
||||
.is_some_and(|id| self.boats.iter().any(|b| b.id == id))
|
||||
&& [
|
||||
@@ -569,6 +569,16 @@ impl Game<'_> {
|
||||
pub(super) fn attack_item_info(&self) -> Option<&horizon_client::items::ItemInfo> {
|
||||
self.items().get(self.attack_item.as_deref()?)
|
||||
}
|
||||
|
||||
/// `Player.attack`: the attack damage attribute, with the held weapon's
|
||||
/// and the effects' modifiers on it.
|
||||
pub(super) fn attack_damage(&mut self) -> f32 {
|
||||
self.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:attack_damage").ok())
|
||||
.unwrap_or(1.0) as f32
|
||||
}
|
||||
|
||||
/// `evaluateWhichHandsToRender`: which of the main and off hand show.
|
||||
pub(super) fn hands_to_render(&self) -> [bool; 2] {
|
||||
let held = |slot| {
|
||||
|
||||
@@ -22,6 +22,10 @@ impl Game<'_> {
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat | Screen::CommandBlock => true,
|
||||
Screen::DebugOptions => self
|
||||
.debug_options
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.search_focused()),
|
||||
Screen::WorldOptions => self.world_options_typing(),
|
||||
Screen::Creative => self.creative_screen.tab().search,
|
||||
Screen::Inventory => {
|
||||
@@ -106,6 +110,7 @@ impl Game<'_> {
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(&text),
|
||||
Screen::CommandBlock => self.command_block_type(&text),
|
||||
Screen::DebugOptions => self.debug_options_type(&text),
|
||||
Screen::WorldOptions => self.world_options_text(Some(&text)),
|
||||
Screen::Creative => self.creative_search_edit(Some(&text)),
|
||||
Screen::Inventory if self.mod_browser_typing() => {
|
||||
|
||||
@@ -577,6 +577,8 @@ impl Game<'_> {
|
||||
ready: &Sender<Ready>,
|
||||
opening: Opening,
|
||||
) -> Result<()> {
|
||||
// MinecraftServer.loadLevel's start: nothing is loaded yet.
|
||||
self.native_server_about_to_start(false);
|
||||
self.native_lifecycle_open_started();
|
||||
// `load_world`, with the world `serve` started preparing.
|
||||
self.join_world_rest()?;
|
||||
@@ -672,6 +674,8 @@ impl Game<'_> {
|
||||
fn stop_integrated(&mut self) -> Result<()> {
|
||||
// The close request: lifecycle observation stops.
|
||||
self.native_lifecycle.begin_close();
|
||||
// MinecraftServer.stopServer's start: everyone is still here.
|
||||
self.native_server_stopping(false);
|
||||
// IntegratedServer.stopServer: teardownPublishedState.
|
||||
if let Some(s) = self.serving.take() {
|
||||
s.flags.published.store(0, Ordering::Relaxed);
|
||||
@@ -956,7 +960,8 @@ impl Game<'_> {
|
||||
.find(|g| g.uuid == owner)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
host.hub.set_game_mode(conn, mode);
|
||||
host.hub
|
||||
.set_game_mode(conn, mode, &self.world, Some(self.collision));
|
||||
}
|
||||
}
|
||||
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
|
||||
|
||||
@@ -22,6 +22,9 @@ pub(super) struct Env<'a, 'g> {
|
||||
game: &'a Game<'g>,
|
||||
}
|
||||
impl ItemEnv for Env<'_, '_> {
|
||||
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
|
||||
self.game.stations().enchantments.in_tag(tag, enchantment)
|
||||
}
|
||||
fn potion_effect_color(&self, contents: &Value) -> Option<i32> {
|
||||
// `PotionContents.getColorOr`: the effect mix when any effect is visible.
|
||||
let id = contents["potion"].as_str();
|
||||
|
||||
@@ -70,6 +70,8 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.eject_record(pos);
|
||||
// ServerPlayerGameMode.useItemOn: useWithoutItem consumed the click.
|
||||
self.trigger_default_block_use(pos);
|
||||
return true;
|
||||
}
|
||||
let Some(stack) = held else { return false };
|
||||
|
||||
@@ -16,6 +16,7 @@ use horizon_client::{
|
||||
},
|
||||
mobs::{Mob, Species, Who, leash::Holder},
|
||||
text::Text,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::{
|
||||
@@ -23,32 +24,87 @@ use horizon_protocol::{
|
||||
entity_data::{Value, serializer},
|
||||
packets::play::{self, Clientbound as C},
|
||||
};
|
||||
use horizon_world::block_collision::BlockCollisionRegistry;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
/// The registries and tags, loaded once per world (a world open or a
|
||||
/// `/reload` forgets them, as `forget_sync_data` does).
|
||||
static SYNC: Mutex<Option<std::result::Result<Arc<SyncData>, String>>> = Mutex::new(None);
|
||||
/// What `sync_data` loaded: whether it was with data packs over the game's
|
||||
/// data, and the registries and tags.
|
||||
type Synced = (bool, std::result::Result<Arc<SyncData>, String>);
|
||||
|
||||
/// The registries and tags, loaded once per world (a world with packs open
|
||||
/// or a `/reload` forgets them, as `forget_sync_data` does).
|
||||
static SYNC: Mutex<Option<Synced>> = Mutex::new(None);
|
||||
|
||||
/// The registries and tags a server sends, loaded from `root` the first
|
||||
/// time, with the world's data packs' recipes and advancements over them.
|
||||
/// time, with the world's data packs' recipes, advancements and dialogs over
|
||||
/// them. What was read with packs is read again for a world without them
|
||||
/// (their dialogs would stay); a world with packs forgets what was read
|
||||
/// before it at open (`finish_open_world`).
|
||||
pub(super) fn sync_data(
|
||||
root: &std::path::Path,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
) -> std::result::Result<Arc<SyncData>, String> {
|
||||
let packs = !overlay.files.is_empty();
|
||||
let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner());
|
||||
sync.get_or_insert_with(|| SyncData::load(root, overlay).map(Arc::new))
|
||||
if sync
|
||||
.as_ref()
|
||||
.is_some_and(|(with_packs, _)| *with_packs != packs)
|
||||
{
|
||||
*sync = None;
|
||||
}
|
||||
sync.get_or_insert_with(|| (packs, SyncData::load(root, overlay).map(Arc::new)))
|
||||
.1
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Let go of what `sync_data` loaded: for a preload no world used
|
||||
/// (game/world_loading.rs).
|
||||
/// Let go of what `sync_data` loaded: a preload no world used
|
||||
/// (game/world_loading.rs), what was read before a world with packs opened,
|
||||
/// or before a `/reload` without a hub.
|
||||
pub(super) fn forget_sync_data() {
|
||||
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
|
||||
/// `/reload` (MinecraftServer.reloadResources) under a running hub: the
|
||||
/// tags, recipes and advancements read again with the world's packs, the
|
||||
/// synchronized registries `kept` (the hub's) as they were, and that becomes
|
||||
/// what `sync_data` gives.
|
||||
pub(super) fn reload_sync_data(
|
||||
root: &std::path::Path,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
kept: &SyncData,
|
||||
) -> std::result::Result<Arc<SyncData>, String> {
|
||||
// Kept pack dialogs count as packs: a world opened without packs after
|
||||
// a `/reload` that dropped them must read its own registries again.
|
||||
let packs = !overlay.files.is_empty() || kept.has_pack_entries();
|
||||
let sync = SyncData::load(root, overlay).map(|mut sync| {
|
||||
sync.keep_registries(root, kept);
|
||||
Arc::new(sync)
|
||||
});
|
||||
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = Some((packs, sync.clone()));
|
||||
sync
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The commands' `minecraft:dialog` ids as the clients get them in the
|
||||
/// configuration phase (`sync`, the world's packs' dialogs among them):
|
||||
/// `/dialog show` parses an id against these and sends its index.
|
||||
pub(super) fn sync_dialog_ids(&mut self, sync: &SyncData) {
|
||||
let Some(names) = sync.dynamic.registries.get("minecraft:dialog") else {
|
||||
return;
|
||||
};
|
||||
let ids = names.iter().map(|(name, _)| name.clone()).collect();
|
||||
self.ui
|
||||
.registries
|
||||
.by_registry
|
||||
.entry("minecraft:dialog".to_owned())
|
||||
.or_default()
|
||||
.ids = ids;
|
||||
}
|
||||
}
|
||||
|
||||
/// The registries and tags a server sends without data packs over them, read
|
||||
/// on a helper thread while a server's world prepares: most worlds have no
|
||||
/// packs, so it is what `sync_data` would load once the world is open.
|
||||
@@ -76,9 +132,10 @@ impl SyncPreload {
|
||||
return;
|
||||
}
|
||||
if let Ok(read) = self.0.join() {
|
||||
SYNC.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.get_or_insert_with(|| read.map(Arc::new));
|
||||
let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if sync.as_ref().is_none_or(|(with_packs, _)| *with_packs) {
|
||||
*sync = Some((false, read.map(Arc::new)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,7 +484,7 @@ fn mob_meta(
|
||||
hub.effect_meta(
|
||||
(m.extra.effects.iter())
|
||||
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
|
||||
.map(|e| (e.0, false)),
|
||||
.map(|e| (e.0, m.extra.ambient_effects.contains(&e.0))),
|
||||
),
|
||||
);
|
||||
defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new())));
|
||||
@@ -1072,7 +1129,11 @@ pub const HELP: &[&str] = &[
|
||||
/// (setting a mode does nothing when it is already theirs). A world open to
|
||||
/// LAN is an integrated server, which leaves out the players who have game
|
||||
/// master permission; a dedicated server does not.
|
||||
fn enforce_guest_game_mode(host: &mut LanHost) {
|
||||
fn enforce_guest_game_mode(
|
||||
host: &mut LanHost,
|
||||
world: &World,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
) {
|
||||
if !host.hub.config.force_game_mode {
|
||||
return;
|
||||
}
|
||||
@@ -1086,7 +1147,7 @@ fn enforce_guest_game_mode(host: &mut LanHost) {
|
||||
.map(|g| g.conn)
|
||||
.collect();
|
||||
for conn in stale {
|
||||
host.hub.set_game_mode(conn, mode);
|
||||
host.hub.set_game_mode(conn, mode, world, Some(collision));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1257,6 +1318,7 @@ impl Game<'_> {
|
||||
host: Option<u128>,
|
||||
) -> Result<()> {
|
||||
let sync = sync_data(&self.options.resources, &self.pack_files())?;
|
||||
self.sync_dialog_ids(&sync);
|
||||
let biome_names: Vec<String> = self
|
||||
.res
|
||||
.biomes()
|
||||
@@ -1513,6 +1575,8 @@ impl Game<'_> {
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
self.save_guest_records(host, g);
|
||||
} else if let Some(saved) = host.owner.as_ref().and_then(|o| o.saved.as_ref()) {
|
||||
self.level.position = saved.position;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1524,7 +1588,7 @@ impl Game<'_> {
|
||||
host.hub.config.default_game_mode = mode;
|
||||
// `setWorldGameType` and `DefaultGameModeCommands.setMode`: a
|
||||
// world that forces its mode puts the guests online in it.
|
||||
enforce_guest_game_mode(host);
|
||||
enforce_guest_game_mode(host, &self.world, self.collision);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1542,7 +1606,7 @@ impl Game<'_> {
|
||||
host.settings.force_game_mode = force;
|
||||
host.hub.config.guest_commands = effective;
|
||||
host.hub.config.force_game_mode = force;
|
||||
enforce_guest_game_mode(host);
|
||||
enforce_guest_game_mode(host, &self.world, self.collision);
|
||||
host.hub.resend_commands();
|
||||
}
|
||||
}
|
||||
@@ -1807,14 +1871,17 @@ impl Game<'_> {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::PlayerChat { sender, .. } => {
|
||||
if let Some(conn) = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.uuid == sender)
|
||||
.map(|g| g.conn)
|
||||
{
|
||||
HostEvent::PlayerChat { sender, text } => {
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == sender) {
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::events::record(
|
||||
"chat",
|
||||
&g.name,
|
||||
sender,
|
||||
serde_json::json!({ "message": text }),
|
||||
);
|
||||
}
|
||||
let conn = g.conn;
|
||||
self.detect_rate_spam(&mut host, conn, false);
|
||||
}
|
||||
}
|
||||
@@ -1825,6 +1892,12 @@ impl Game<'_> {
|
||||
{
|
||||
// PlayerList.placeNewPlayer, then the broadcast join message.
|
||||
super::dedicated::player_joined(g.uuid, &g.name);
|
||||
super::dedicated::events::record(
|
||||
"join",
|
||||
&g.name,
|
||||
g.uuid,
|
||||
serde_json::json!({ "address": g.address.ip().to_string() }),
|
||||
);
|
||||
let [x, y, z] = g.position;
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
@@ -1878,6 +1951,12 @@ impl Game<'_> {
|
||||
host.play.remove(&guest.conn);
|
||||
self.guest_combat.remove(&guest.conn);
|
||||
if self.options.dedicated.is_some() && guest.placed {
|
||||
super::dedicated::events::record(
|
||||
"leave",
|
||||
&guest.name,
|
||||
guest.uuid,
|
||||
serde_json::json!({ "reason": guest.left_reason }),
|
||||
);
|
||||
super::dedicated::log(
|
||||
"INFO",
|
||||
&format!("{} lost connection: {}", guest.name, guest.left_reason),
|
||||
@@ -1931,10 +2010,24 @@ impl Game<'_> {
|
||||
.take()
|
||||
.expect("the hub is back after the menu events");
|
||||
}
|
||||
self.lan_flush_effects(&mut host);
|
||||
if !paused {
|
||||
self.tick_guests(&mut host);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
// A blast from a guest's attack, and a mace's wind burst.
|
||||
self.flush_lan_explosions();
|
||||
let _ = self.flush_wind_bursts();
|
||||
// A sweep attack's reach to the other players from a guest's attack.
|
||||
self.flush_sweeps();
|
||||
// A reeled-in hook pulling a player.
|
||||
self.flush_hook_pulls();
|
||||
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
|
||||
self.flush_lan_particles();
|
||||
let mut host = self
|
||||
.lan
|
||||
.take()
|
||||
.expect("the hub is back after the guest flushes");
|
||||
self.lan_flush_effects(&mut host);
|
||||
self.lan_living_flags(&mut host);
|
||||
self.lan_stuck(&mut host, paused);
|
||||
self.tick_server_travel(&mut host);
|
||||
@@ -1949,15 +2042,6 @@ impl Game<'_> {
|
||||
self.flush_native_net(&mut host);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
self.lan = Some(host);
|
||||
// A blast from a guest's attack, and a mace's wind burst.
|
||||
self.flush_lan_explosions();
|
||||
let _ = self.flush_wind_bursts();
|
||||
// A sweep attack's reach to the other players from a guest's attack.
|
||||
self.flush_sweeps();
|
||||
// A reeled-in hook pulling a player.
|
||||
self.flush_hook_pulls();
|
||||
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
|
||||
self.flush_lan_particles();
|
||||
self.flush_native_sounds();
|
||||
}
|
||||
|
||||
@@ -2271,38 +2355,26 @@ impl Game<'_> {
|
||||
}
|
||||
seen.insert(m.id, (m.hurt_time, dead, swing));
|
||||
}
|
||||
// LivingEntity.hurtServer for a player: a hit that restarted the hurt
|
||||
// time and was not blocked by a shield broadcasts the damage event,
|
||||
// so the clients around (and the player's own) flash it red.
|
||||
let host_player = self.player.as_ref().map(|p| {
|
||||
(
|
||||
host.host_entity,
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
p,
|
||||
)
|
||||
});
|
||||
let guests = host.hub.guests.iter().filter(|g| host.hub.here(g));
|
||||
let players: Vec<_> = host_player
|
||||
.into_iter()
|
||||
.chain(guests.filter_map(|g| g.motion.as_deref().map(|m| (g.entity_id, g.position, m))))
|
||||
.map(|(id, at, p)| {
|
||||
(
|
||||
id,
|
||||
at,
|
||||
p.health.hurt_animation_time,
|
||||
p.health.dead_or_dying(),
|
||||
p.hurt_log.last(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (id, at, hurt, dead, record) in players {
|
||||
// LivingEntity.hurtServer for the in-frame LAN host's player (guests
|
||||
// broadcast directly from HealthEvent::DamageBroadcast): a hit that
|
||||
// restarted the hurt time and was not blocked by a shield broadcasts
|
||||
// the damage event, so the clients around flash it red.
|
||||
if let Some(p) = self.player.as_ref() {
|
||||
let id = host.host_entity;
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let hurt = p.health.hurt_animation_time;
|
||||
let dead = p.health.dead_or_dying();
|
||||
let old = host
|
||||
.mob_state
|
||||
.get(&self.dim)
|
||||
.and_then(|s| s.get(&(id as u32)));
|
||||
if hurt > old.map_or(0, |o| o.0) && !dead {
|
||||
// Starving keeps no record: any type flashes the same.
|
||||
let kind = record.map_or("minecraft:generic", |r| r.source.as_str());
|
||||
let kind = p
|
||||
.health
|
||||
.hurt
|
||||
.last_source
|
||||
.and_then(|s| self.motion.health.damage.definition(s))
|
||||
.map_or("minecraft:generic", |d| d.name.as_str());
|
||||
host.hub.damage_event(id, kind, None, None, at);
|
||||
}
|
||||
seen.insert(id as u32, (hurt, dead, None));
|
||||
@@ -2341,11 +2413,12 @@ impl Game<'_> {
|
||||
// covers what the player tracks (`ChunkTrackingView`, within its view
|
||||
// distance + 1) and those chunks' lit neighbours.
|
||||
let radius = host.hub.config.view_distance + 1;
|
||||
let rule = self.ui.rules.bool("spectators_generate_chunks");
|
||||
self.streaming.extra = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| host.hub.here(g))
|
||||
.filter(|g| host.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator()))
|
||||
.map(|g| (g.chunk(), radius))
|
||||
.chain(
|
||||
self.arrival_chunks(self.dim)
|
||||
|
||||
@@ -116,6 +116,9 @@ pub(super) struct GuestPlay {
|
||||
/// The guest's cast fishing hook (`Player.fishing`).
|
||||
pub(super) hook: Option<super::fishing::Hook>,
|
||||
sculk: super::sculk::PlayerEvents,
|
||||
/// What `ServerPlayer` last recorded for the guest's scoreboard criteria
|
||||
/// (`lastRecordedHealthAndAbsorption` and the others).
|
||||
score_recorded: super::scoreboard::Recorded,
|
||||
}
|
||||
|
||||
/// A guest's command waiting for the hub (`LanHost::commands`).
|
||||
@@ -354,6 +357,8 @@ impl Game<'_> {
|
||||
play.equipped = None;
|
||||
play.potions = Default::default();
|
||||
play.sculk = Default::default();
|
||||
// A new ServerPlayer has everything to record again.
|
||||
play.score_recorded = Default::default();
|
||||
}
|
||||
let g = &mut host.hub.guests[i];
|
||||
// The guest's body.
|
||||
@@ -382,6 +387,8 @@ impl Game<'_> {
|
||||
// FireworkRocketItem.use boosts only a gliding player.
|
||||
motion.fall_flying = g.fall_flying;
|
||||
motion.invulnerable = motion.may_fly;
|
||||
// `ServerPlayer.isInvulnerableTo`'s connection term, as of now.
|
||||
motion.client_loaded = g.client_loaded();
|
||||
let _ = motion.set_health(g.health, false);
|
||||
motion.health.food.food = g.food;
|
||||
motion.health.food.saturation = g.saturation;
|
||||
@@ -442,6 +449,12 @@ impl Game<'_> {
|
||||
self.right = play.using.is_some() || self.using.is_some();
|
||||
// The guest's vehicle (Entity.vehicle) for its turn.
|
||||
let riding = std::mem::replace(&mut self.riding, g.vehicle);
|
||||
// `Entity.isPassenger()` for that turn, from the same vehicle: the
|
||||
// guest's body keeps no swim (`Entity.updateSwimming` needs it false).
|
||||
// Taken from `g.vehicle` each turn, so boarding and getting out land.
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.is_passenger = g.vehicle.is_some();
|
||||
}
|
||||
let host_station = self.station.take();
|
||||
// The guest's bed, sleep counter and respawn point; the host's
|
||||
// respawn point, anchor and action bar stay theirs.
|
||||
@@ -564,6 +577,15 @@ impl Game<'_> {
|
||||
/// The second half of `as_guest`: the guest's state back into their
|
||||
/// record, the game's back in place, and what the turn changed sent.
|
||||
pub(super) fn leave_guest(&mut self, host: &mut LanHost, swap: GuestSwap) {
|
||||
let turn_events = self
|
||||
.player
|
||||
.as_mut()
|
||||
.map(|p| std::mem::take(&mut p.events))
|
||||
.unwrap_or_default();
|
||||
if !turn_events.is_empty() {
|
||||
self.record_combat(&turn_events);
|
||||
self.wear_armor(&turn_events);
|
||||
}
|
||||
// A totem that saved the guest this turn (its hurt set it off).
|
||||
let totem = self.apply_totem();
|
||||
let GuestSwap {
|
||||
@@ -846,7 +868,8 @@ impl Game<'_> {
|
||||
}
|
||||
if new_mode != g.game_mode {
|
||||
let conn = g.conn;
|
||||
host.hub.set_game_mode(conn, new_mode);
|
||||
host.hub
|
||||
.set_game_mode(conn, new_mode, &self.world, Some(self.collision));
|
||||
}
|
||||
// LivingEntity.checkTotemDeathProtection's entity event, to every
|
||||
// client around, the guest's own included; this window draws it too.
|
||||
@@ -859,6 +882,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
host.play.insert(conn, play);
|
||||
for event in turn_events {
|
||||
match event {
|
||||
MotionEvent::Health(horizon_client::health::HealthEvent::DamageBroadcast {
|
||||
source,
|
||||
}) => self.guest_damage_broadcast(host, conn, source),
|
||||
MotionEvent::HurtSound(sound) => self.guest_hurt_sound(host, conn, sound),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.stat_criteria_changed(&name, stat_events);
|
||||
if let Some(pos) = opened
|
||||
&& !self.lan_open_container(host, conn, pos)
|
||||
@@ -887,6 +919,39 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.broadcastDamageEvent` for a guest that took an unblocked hit.
|
||||
fn guest_damage_broadcast(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
source: horizon_world::damage_source::DamageTypeId,
|
||||
) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let (id, at) = (g.entity_id, g.position);
|
||||
let kind = self
|
||||
.motion
|
||||
.health
|
||||
.damage
|
||||
.definition(source)
|
||||
.map_or("minecraft:generic", |d| d.name.as_str());
|
||||
host.hub.damage_event(id, kind, None, None, at);
|
||||
}
|
||||
|
||||
/// `LivingEntity.playHurtSound` for a guest: the server's `Entity.playSound`
|
||||
/// reaches every player around but the hurt one, whose own client plays it
|
||||
/// from `handleDamageEvent` (#4149).
|
||||
fn guest_hurt_sound(&mut self, host: &mut LanHost, conn: u32, sound: &str) {
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return;
|
||||
};
|
||||
let at = g.position;
|
||||
let source = horizon_client::settings::sound_source(sound) as i32;
|
||||
self.audio.play_unshared(sound, Some(at), 1.0, 1.0);
|
||||
host.hub.sound(sound, at, 1.0, 1.0, source, Some(conn));
|
||||
}
|
||||
|
||||
/// `ServerLevel.players()` in turn: the host's own player, then each
|
||||
/// guest in the level in place with their state swapped in (`as_guest`).
|
||||
/// What reaches the players around a point (a beacon's powers, a splash
|
||||
@@ -928,6 +993,12 @@ impl Game<'_> {
|
||||
/// Hand a container the guest opened over (ServerPlayer.openMenu);
|
||||
/// false when it is not one a guest can open.
|
||||
fn lan_open_container(&mut self, host: &mut LanHost, conn: u32, pos: (i32, i32, i32)) -> bool {
|
||||
// A chest minecart, hopper minecart or chest boat keeps its slots
|
||||
// under a placeholder key (`ContainerEntity`): the menu is the
|
||||
// vehicle's own, not a block's.
|
||||
if pos.1 == super::minecarts::CART_KEY_Y {
|
||||
return self.lan_open_vehicle_container(host, conn, pos);
|
||||
}
|
||||
let name = &self
|
||||
.res
|
||||
.facts()
|
||||
@@ -1020,6 +1091,51 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// `ContainerEntity.interactWithContainerVehicle` for a guest: the menu
|
||||
/// the vehicle's own `MenuProvider.createMenu` builds - three rows of a
|
||||
/// `ChestMenu` for a chest boat or a chest minecart, a `HopperMenu` for a
|
||||
/// hopper minecart - titled with the vehicle's name.
|
||||
fn lan_open_vehicle_container(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
pos: (i32, i32, i32),
|
||||
) -> bool {
|
||||
let entity_type = if let Some(cart) = self.carts.iter().find(|c| c.id as i32 == pos.2) {
|
||||
cart.kind.id().to_owned()
|
||||
} else if let Some(boat) = self.boats.iter().find(|b| b.id as i32 == pos.2) {
|
||||
if !boat.chest {
|
||||
return false;
|
||||
}
|
||||
boat.entity_type()
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
// `createMenu` (ChestMenu.threeRows or a HopperMenu), and
|
||||
// `MenuProvider.getDisplayName`, the vehicle's name key.
|
||||
let Some((menu, _)) = horizon_client::containers::vehicle_menu(&entity_type) else {
|
||||
return false;
|
||||
};
|
||||
let title = horizon_protocol::text::translatable(
|
||||
&horizon_client::containers::vehicle_menu_name(&entity_type),
|
||||
&[],
|
||||
);
|
||||
let slots = Slots::Block(pos);
|
||||
let Some(items) = host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| slots.read(&self.world, g))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
true
|
||||
}
|
||||
|
||||
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
|
||||
/// it no longer counts among the openers of the lid, and the lid's close
|
||||
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
|
||||
@@ -1035,11 +1151,46 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ContainerEntity`'s `Container.stopOpen` when a vehicle menu closes
|
||||
/// (`AbstractChestBoat.stopOpen` / `MinecartChest.stopOpen`): the menu's
|
||||
/// slots go back to the vehicle, the shared placeholder leaves
|
||||
/// `world.block_entities`, and CONTAINER_CLOSE fires at the vehicle with
|
||||
/// the closing player as the source. A hopper minecart keeps
|
||||
/// `Container.stopOpen`'s no-op. Called by the close packet and by the
|
||||
/// `ServerPlayer.tick` reach/destroyed close.
|
||||
fn close_guest_vehicle_menu(&mut self, host: &LanHost, conn: u32, key: (i32, i32, i32)) {
|
||||
self.store_cart_items(key);
|
||||
self.world.block_entities.remove(&key);
|
||||
let at = if let Some(b) = self.boats.iter().find(|b| b.id as i32 == key.2) {
|
||||
Some([b.position.x, b.position.y, b.position.z])
|
||||
} else if let Some(c) = self.carts.iter().find(|c| c.id as i32 == key.2) {
|
||||
(c.kind == super::minecarts::CartKind::Chest).then_some([
|
||||
c.position.x,
|
||||
c.position.y,
|
||||
c.position.z,
|
||||
])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(at) = at {
|
||||
let spect = host.hub.guest(conn).is_some_and(|g| g.spectator());
|
||||
let ctx = horizon_client::sculk::Context {
|
||||
source: Some(horizon_client::mobs::Who::Player),
|
||||
spectator: spect,
|
||||
targetable: !spect,
|
||||
living: true,
|
||||
player: true,
|
||||
..Default::default()
|
||||
};
|
||||
self.post_game_event("minecraft:container_close", at, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayer.tick`'s `stillValid`: a guest's block container closes
|
||||
/// once the guest is out of reach of its block (a removed block closes it
|
||||
/// in the hub's `sync_containers`).
|
||||
fn lan_containers_tick(&mut self, host: &mut LanHost) {
|
||||
let far: Vec<u32> = host
|
||||
let far: Vec<(u32, Option<(i32, i32, i32)>)> = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
@@ -1053,11 +1204,20 @@ impl Game<'_> {
|
||||
})
|
||||
})
|
||||
})
|
||||
.map(|g| g.conn)
|
||||
.map(|g| {
|
||||
let vehicle = g.menu.as_ref().and_then(|m| match m.slots {
|
||||
Slots::Block(key) if key.1 == super::minecarts::CART_KEY_Y => Some(key),
|
||||
_ => None,
|
||||
});
|
||||
(g.conn, vehicle)
|
||||
})
|
||||
.collect();
|
||||
for conn in far {
|
||||
for (conn, vehicle) in far {
|
||||
self.guest_container_closed(host, conn);
|
||||
host.hub.close_container(conn);
|
||||
if let Some(key) = vehicle {
|
||||
self.close_guest_vehicle_menu(host, conn, key);
|
||||
}
|
||||
if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) {
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
@@ -1424,6 +1584,14 @@ impl Game<'_> {
|
||||
g.guest_use = Some(GuestUse::OnBlock(hand));
|
||||
let swings = g.swings_started;
|
||||
let consumed = matches!(g.use_item(), Ok(Some(_)));
|
||||
// handleUseItemOn: a consuming useItemOn triggers
|
||||
// ANY_BLOCK_USE with the hand's stack as it now is.
|
||||
if consumed && !g.use_failed {
|
||||
let stack = g
|
||||
.with_hand(hand, |g| g.held().cloned())
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
|
||||
g.trigger_any_block_use(p.pos, &stack);
|
||||
}
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
(g.swings_started != swings, consumed, attempt)
|
||||
@@ -1586,7 +1754,11 @@ impl Game<'_> {
|
||||
// mount's inventory (`HasCustomInventoryScreen`).
|
||||
S::PlayerCommand(p) if p.action == 5 => {
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.open_ridden_mount_menu();
|
||||
// HasCustomInventoryScreen: a tamed mount's horse
|
||||
// inventory, or a chest boat's chest.
|
||||
if !g.open_ridden_mount_menu() {
|
||||
g.open_ridden_chest_boat();
|
||||
}
|
||||
});
|
||||
}
|
||||
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
|
||||
@@ -1654,6 +1826,17 @@ impl Game<'_> {
|
||||
self.drop_guest_items(host, gi, drops);
|
||||
}
|
||||
S::ContainerClose(p) => {
|
||||
// A vehicle's menu closed: its slots go back to the vehicle
|
||||
// (`ContainerEntity`), and a chest boat's `stopOpen` fires
|
||||
// CONTAINER_CLOSE with the player as the source.
|
||||
let vehicle = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| m.window == p.container)
|
||||
.and_then(|m| match m.slots {
|
||||
Slots::Block(key) if key.1 == super::minecarts::CART_KEY_Y => Some(key),
|
||||
_ => None,
|
||||
});
|
||||
if host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
@@ -1661,6 +1844,9 @@ impl Game<'_> {
|
||||
{
|
||||
self.guest_container_closed(host, conn);
|
||||
}
|
||||
if let Some(key) = vehicle {
|
||||
self.close_guest_vehicle_menu(host, conn, key);
|
||||
}
|
||||
host.hub.container_closed(conn, p.container);
|
||||
self.guest_carried_back(host, gi);
|
||||
}
|
||||
@@ -2263,6 +2449,37 @@ impl Game<'_> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if let Some(index) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.position(|m| m.id as i32 == id && m.alive())
|
||||
.filter(|_| self.dragon.as_ref().is_none_or(|d| d.id as i32 != id))
|
||||
{
|
||||
if host.hub.guests[gi].vehicle == Some(self.mobs[index].id) {
|
||||
return;
|
||||
}
|
||||
// A mace's smash sets the attacker's velocity and sends it at once, before the
|
||||
// Wind Burst's push (MaceItem.hurtEnemy, then the post-attack effects).
|
||||
let sent = self
|
||||
.as_guest(host, conn, |g| {
|
||||
let marked = g
|
||||
.player
|
||||
.as_mut()
|
||||
.is_some_and(|p| std::mem::take(&mut p.health.sync_velocity));
|
||||
g.attack_mob(index);
|
||||
g.swing_arm();
|
||||
let p = g.player.as_mut()?;
|
||||
let sent = std::mem::replace(&mut p.health.sync_velocity, marked);
|
||||
sent.then_some(p.velocity)
|
||||
})
|
||||
.flatten();
|
||||
self.send_guest_motion(host, conn, sent);
|
||||
// The mob now answers to this guest (game/lan_mobs.rs).
|
||||
if let Ok(id) = u32::try_from(id) {
|
||||
host.mob_players.insert(id, conn);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (old_yaw, old_pitch) = (host.hub.guests[gi].yaw, host.hub.guests[gi].pitch);
|
||||
if self.look_at_entity(host, gi, id, None).is_none() {
|
||||
return;
|
||||
@@ -2621,7 +2838,8 @@ impl Game<'_> {
|
||||
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
|
||||
// respawned player only watches.
|
||||
if self.level.hardcore {
|
||||
host.hub.set_game_mode(conn, SPECTATOR);
|
||||
host.hub
|
||||
.set_game_mode(conn, SPECTATOR, &self.world, Some(self.collision));
|
||||
}
|
||||
if let Some(config) = elsewhere {
|
||||
self.respawn_trip(conn, config);
|
||||
@@ -2649,6 +2867,7 @@ impl Game<'_> {
|
||||
});
|
||||
if found.is_none() {
|
||||
// TeleportTransition.missingRespawnBlock.
|
||||
host.hub.missing_respawn_block(conn);
|
||||
self.world_spawn_trip(conn);
|
||||
}
|
||||
host.hub.teleport(i, Some(at));
|
||||
@@ -2778,6 +2997,10 @@ impl Game<'_> {
|
||||
// Player.tick's statistics and ServerPlayer.tick's triggers, then
|
||||
// PlayerAdvancements.flushDirty.
|
||||
let sleeping = host.hub.guests[gi].sleeping;
|
||||
// ServerPlayer.doTick's updateScoreForCriteria: the health, food,
|
||||
// armor, experience and level objectives follow the guest.
|
||||
let mut recorded =
|
||||
std::mem::take(&mut host.play.entry(conn).or_default().score_recorded);
|
||||
self.as_guest(host, conn, |g| {
|
||||
g.tick_fishing();
|
||||
g.tick_maps();
|
||||
@@ -2786,9 +3009,11 @@ impl Game<'_> {
|
||||
g.count_tick_stats(sleeping);
|
||||
g.tick_advancements();
|
||||
g.update_player_attributes();
|
||||
g.record_player_scores(&mut recorded);
|
||||
// Native mods, at the end of ServerPlayer.tick.
|
||||
g.native_player_tick();
|
||||
});
|
||||
host.play.entry(conn).or_default().score_recorded = recorded;
|
||||
self.lan_map_updates(host, conn);
|
||||
host.hub.flush_progress(conn);
|
||||
self.guest_dig_feedback(host, conn);
|
||||
@@ -2850,6 +3075,12 @@ impl Game<'_> {
|
||||
horizon_client::scoreboard::death_message_reaches(board, &name, other)
|
||||
});
|
||||
if self.options.dedicated.is_some() {
|
||||
super::dedicated::events::record(
|
||||
"death",
|
||||
&name,
|
||||
uuid,
|
||||
serde_json::json!({ "message": text.plain() }),
|
||||
);
|
||||
// Only a message for everyone is logged (`broadcastSystemMessage`).
|
||||
if board.players_team(&name).is_none_or(|t| {
|
||||
t.death_message_visibility == horizon_client::scoreboard::Visibility::Always
|
||||
@@ -3233,13 +3464,13 @@ impl Game<'_> {
|
||||
});
|
||||
for event in events {
|
||||
match event {
|
||||
MotionEvent::Health(horizon_client::health::HealthEvent::DamageBroadcast {
|
||||
source,
|
||||
}) => self.guest_damage_broadcast(host, conn, source),
|
||||
// LivingEntity.playHurtSound: the server's `Entity.playSound`
|
||||
// reaches every player around but the hurt one, whose own
|
||||
// client plays it from handleDamageEvent (#4149).
|
||||
MotionEvent::HurtSound(sound) => {
|
||||
let source = horizon_client::settings::sound_source(sound) as i32;
|
||||
host.hub.sound(sound, at, 1.0, 1.0, source, Some(conn));
|
||||
}
|
||||
MotionEvent::HurtSound(sound) => self.guest_hurt_sound(host, conn, sound),
|
||||
// LivingEntity.checkFallDamage: the crumbs reach every player.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
|
||||
state,
|
||||
|
||||
@@ -104,12 +104,15 @@ impl Game<'_> {
|
||||
)
|
||||
})
|
||||
}
|
||||
/// The mobs on the player's lead within 32 blocks of a point.
|
||||
/// The mobs on the acting player's lead within 32 blocks of a point
|
||||
/// (`Leashable.leashableInArea` with `getLeashHolder() == player`).
|
||||
pub(super) fn player_leashed(&self, around: glam::DVec3) -> Vec<usize> {
|
||||
let acting = self.acting_uuid();
|
||||
(0..self.mobs.len())
|
||||
.filter(|&i| {
|
||||
let m = &self.mobs[i];
|
||||
m.extra.leash == Some(Holder::Player)
|
||||
&& m.extra.leash_player.is_none_or(|p| p == acting)
|
||||
&& (m.position.x - around.x).abs() <= 16.0
|
||||
&& (m.position.y - around.y).abs() <= 16.0
|
||||
&& (m.position.z - around.z).abs() <= 16.0
|
||||
@@ -334,9 +337,11 @@ impl Game<'_> {
|
||||
}
|
||||
if !attached && !sneaking {
|
||||
let player = self.player_center();
|
||||
let holder = self.acting_player_uuid();
|
||||
for i in existing {
|
||||
if self.can_attach_lead(i, player) {
|
||||
self.mobs[i].extra.leash = Some(Holder::Player);
|
||||
self.mobs[i].extra.leash_player = Some(holder);
|
||||
attached = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ impl Levels {
|
||||
self.parked.get(&dim).map(|level| &level.world)
|
||||
}
|
||||
|
||||
/// The world of the level not in place, in `dim`, to change.
|
||||
pub(super) fn parked_world_mut(&mut self, dim: Dim) -> Option<&mut World> {
|
||||
self.parked.get_mut(&dim).map(|level| &mut level.world)
|
||||
}
|
||||
|
||||
/// The level not in place, in `dim`.
|
||||
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
|
||||
self.parked.get_mut(&dim)
|
||||
|
||||
@@ -456,8 +456,7 @@ impl Game<'_> {
|
||||
let attribute = if spin > 0.0 {
|
||||
spin
|
||||
} else {
|
||||
1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage)
|
||||
+ self.effect_attack_bonus()
|
||||
self.attack_damage()
|
||||
};
|
||||
let base = sweep_base_damage(ratio, attribute, scale);
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
|
||||
@@ -14,18 +14,136 @@ use horizon_client::{
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A decoration: sprite, map position (-128..127, half pixels) and rotation
|
||||
/// (0..15), and the player it marks (an item's own decorations mark none).
|
||||
/// What a decoration is filed under in `MapItemSavedData.decorations` (a
|
||||
/// `LinkedHashMap<String, MapDecoration>`): a player's marker by their
|
||||
/// connection, the banner at a position (`MapBanner.getId`), a framed map's
|
||||
/// frame by its entity (`getFrameKey`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum Key {
|
||||
Player(u32),
|
||||
/// An item's `minecraft:map_decorations` (an explorer map's target), or a
|
||||
/// decoration a server sent (`addClientSideDecorations`).
|
||||
Item,
|
||||
Banner([i32; 3]),
|
||||
Frame(i32),
|
||||
}
|
||||
/// A decoration (`MapDecoration`): sprite, map position (-128..127, half
|
||||
/// pixels), rotation (0..15) and name, filed under its key.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct Decoration {
|
||||
pub sprite: &'static str,
|
||||
pub x: i8,
|
||||
pub y: i8,
|
||||
pub rot: u8,
|
||||
pub holder: Option<u32>,
|
||||
pub name: Option<String>,
|
||||
pub key: Key,
|
||||
}
|
||||
/// `MapBanner`: a banner a map marks, as `banners` saves it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct MapBanner {
|
||||
pub pos: [i32; 3],
|
||||
/// `DyeColor.getName` of the banner's base colour.
|
||||
pub color: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
impl MapBanner {
|
||||
/// `MapBanner.getDecoration`: the marker of the banner's colour.
|
||||
fn sprite(&self) -> &'static str {
|
||||
decoration_sprite(&format!("banner_{}", self.color)).unwrap_or(BANNER_WHITE)
|
||||
}
|
||||
/// `MapBanner.CODEC`.
|
||||
fn to_nbt(&self) -> Tag {
|
||||
let mut entries = vec![
|
||||
("pos", Tag::IntArray(self.pos.to_vec())),
|
||||
("color", Tag::String(self.color.as_str().into())),
|
||||
];
|
||||
if let Some(name) = &self.name {
|
||||
entries.push(("name", Tag::String(name.as_str().into())));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
}
|
||||
fn from_nbt(tag: &Tag) -> Option<Self> {
|
||||
// DyeColor.CODEC is read leniently: white when missing or unknown.
|
||||
let color = horizon_client::save::text(tag.get("color"))
|
||||
.filter(|c| decoration_sprite(&format!("banner_{c}")).is_some())
|
||||
.unwrap_or_else(|| "white".to_owned());
|
||||
Some(Self {
|
||||
pos: block_pos(tag.get("pos"))?,
|
||||
color,
|
||||
name: horizon_client::save::text(tag.get("name")),
|
||||
})
|
||||
}
|
||||
}
|
||||
/// `MapFrame`: an item frame a map hangs in, as `frames` saves it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct MapFrame {
|
||||
pub pos: [i32; 3],
|
||||
/// The frame's `Direction.get2DDataValue` times 90.
|
||||
pub rotation: i32,
|
||||
pub entity_id: i32,
|
||||
}
|
||||
impl MapFrame {
|
||||
/// `MapFrame.CODEC`.
|
||||
fn to_nbt(self) -> Tag {
|
||||
Tag::compound([
|
||||
("pos", Tag::IntArray(self.pos.to_vec())),
|
||||
("rotation", Tag::Int(self.rotation)),
|
||||
("entity_id", Tag::Int(self.entity_id)),
|
||||
])
|
||||
}
|
||||
fn from_nbt(tag: &Tag) -> Option<Self> {
|
||||
let int = |k: &str| horizon_client::save::number(tag.get(k)).map(|v| v as i32);
|
||||
Some(Self {
|
||||
pos: block_pos(tag.get("pos"))?,
|
||||
rotation: int("rotation")?,
|
||||
entity_id: int("entity_id")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
/// `BlockPos.CODEC`: three ints, as an int array or a list.
|
||||
fn block_pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
|
||||
match tag? {
|
||||
Tag::IntArray(v) if v.len() == 3 => Some([v[0], v[1], v[2]]),
|
||||
Tag::List(l) if l.len() == 3 => {
|
||||
let int = |t: &Tag| match t {
|
||||
Tag::Int(v) => Some(*v),
|
||||
_ => None,
|
||||
};
|
||||
Some([int(&l[0])?, int(&l[1])?, int(&l[2])?])
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The item frame a map hangs in, as `tickCarriedBy` reads it
|
||||
/// (`placedInFrame`): the block it is in, its `Direction.get3DDataValue`
|
||||
/// (down 0, up 1, north 2, south 3, west 4, east 5) and its entity id.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Framed {
|
||||
pos: [i32; 3],
|
||||
facing: u8,
|
||||
id: i32,
|
||||
}
|
||||
/// `Direction.get2DDataValue` of a 3D data value: south 0, west 1, north 2,
|
||||
/// east 3, and -1 for up and down.
|
||||
fn direction_2d(facing: u8) -> i32 {
|
||||
match facing {
|
||||
3 => 0,
|
||||
4 => 1,
|
||||
2 => 2,
|
||||
5 => 3,
|
||||
_ => -1,
|
||||
}
|
||||
}
|
||||
/// The host among a map's holders (the guests are their connections).
|
||||
const HOST: u32 = u32::MAX;
|
||||
/// `MapDecorationTypes` the markers use: the player's arrow, the arrows that
|
||||
/// replace it past the map's edge, the item frame's, the white banner's (the
|
||||
/// fallback colour).
|
||||
const PLAYER: &str = "map/decorations/player";
|
||||
const FRAME: &str = "map/decorations/frame";
|
||||
const BANNER_WHITE: &str = "map/decorations/white_banner";
|
||||
const PLAYER_OFF_MAP: &str = "map/decorations/player_off_map";
|
||||
const PLAYER_OFF_LIMITS: &str = "map/decorations/player_off_limits";
|
||||
/// A player carrying a map, or seeing it framed
|
||||
/// (`MapItemSavedData.HoldingPlayer`): the column sweep's step, and what
|
||||
/// changed since the last `ClientboundMapItemDataPacket` it was sent.
|
||||
@@ -39,6 +157,9 @@ struct Holder {
|
||||
dirty: Option<[i32; 4]>,
|
||||
decorations: bool,
|
||||
tick: u32,
|
||||
/// Wore map invisibility gear on its last tick
|
||||
/// (`MapItemSavedData.hasMapInvisibilityItemEquipped`).
|
||||
invisible: bool,
|
||||
}
|
||||
impl Holder {
|
||||
fn new(who: u32) -> Self {
|
||||
@@ -50,6 +171,7 @@ impl Holder {
|
||||
dirty: Some([0, 0, 127, 127]),
|
||||
decorations: true,
|
||||
tick: 0,
|
||||
invisible: false,
|
||||
}
|
||||
}
|
||||
/// Still carrying the map, or seeing it framed (frames tick every 10).
|
||||
@@ -66,10 +188,9 @@ pub(super) struct MapData {
|
||||
pub unlimited: bool,
|
||||
pub colors: Vec<u8>,
|
||||
pub decorations: Vec<Decoration>,
|
||||
/// `MapBanner` and `MapFrame` entries as saved (`banners`, `frames`), kept
|
||||
/// so a save does not drop them.
|
||||
pub banners: Vec<Tag>,
|
||||
frames: Vec<Tag>,
|
||||
/// `bannerMarkers` and `frameMarkers` (`banners`, `frames` when saved).
|
||||
pub banners: Vec<MapBanner>,
|
||||
frames: Vec<MapFrame>,
|
||||
holders: Vec<Holder>,
|
||||
/// Changed since the last save / texture upload.
|
||||
dirty: bool,
|
||||
@@ -130,8 +251,10 @@ impl MapData {
|
||||
self.holders.retain(|h| h.holds(now));
|
||||
let holders = &self.holders;
|
||||
let before = self.decorations.len();
|
||||
self.decorations
|
||||
.retain(|d| d.holder.is_none_or(|w| holders.iter().any(|h| h.who == w)));
|
||||
self.decorations.retain(|d| match d.key {
|
||||
Key::Player(w) => holders.iter().any(|h| h.who == w),
|
||||
_ => true,
|
||||
});
|
||||
if self.decorations.len() != before {
|
||||
self.decorations_changed();
|
||||
}
|
||||
@@ -142,31 +265,195 @@ impl MapData {
|
||||
h.decorations = true;
|
||||
}
|
||||
}
|
||||
/// `addDecoration` / `removeDecoration` for `holder`'s marker: in place,
|
||||
/// so that the order (the drawing order) stays.
|
||||
fn set_marker(&mut self, holder: u32, marker: Option<Decoration>) {
|
||||
let i = self
|
||||
.decorations
|
||||
.iter()
|
||||
.position(|d| d.holder == Some(holder));
|
||||
let changed = match (i, marker) {
|
||||
(Some(i), Some(d)) if self.decorations[i] == d => false,
|
||||
(Some(i), Some(d)) => {
|
||||
self.decorations[i] = d;
|
||||
true
|
||||
}
|
||||
(Some(i), None) => {
|
||||
self.decorations.remove(i);
|
||||
true
|
||||
}
|
||||
(None, Some(d)) => {
|
||||
self.decorations.push(d);
|
||||
true
|
||||
}
|
||||
(None, None) => false,
|
||||
/// `MapItemSavedData.removeDecoration`: whatever `key` names goes, and
|
||||
/// every holder is told the decorations changed, found or not.
|
||||
fn remove_decoration(&mut self, key: Key) {
|
||||
self.decorations.retain(|d| d.key != key);
|
||||
self.decorations_changed();
|
||||
}
|
||||
/// `MapItemSavedData.addDecoration` with `calculateDecorationLocationAndType`
|
||||
/// and `calculateRotation`: `sprite` at the world position `x_pos`,
|
||||
/// `z_pos` under `key`, in place when the key has a decoration so that the
|
||||
/// order (the drawing order) stays. A position off the map leaves it out
|
||||
/// (a player's turns into an off-map arrow, then nothing past 320 without
|
||||
/// unlimited tracking) and removes what the key had. `game_time` is the
|
||||
/// level's: without a level the Nether's spinning rotation is not used.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_decoration(
|
||||
&mut self,
|
||||
sprite: &'static str,
|
||||
game_time: Option<i64>,
|
||||
key: Key,
|
||||
x_pos: f64,
|
||||
z_pos: f64,
|
||||
y_rot: f64,
|
||||
name: Option<String>,
|
||||
) {
|
||||
let scaling = (1 << self.scale) as f32;
|
||||
let dx = (x_pos - f64::from(self.center[0])) as f32 / scaling;
|
||||
let dz = (z_pos - f64::from(self.center[1])) as f32 / scaling;
|
||||
let inside = (-63.0..=63.0).contains(&dx) && (-63.0..=63.0).contains(&dz);
|
||||
let shown = if sprite != PLAYER {
|
||||
(inside || self.unlimited).then_some(sprite)
|
||||
} else if inside {
|
||||
Some(sprite)
|
||||
} else if dx.abs() < 320.0 && dz.abs() < 320.0 {
|
||||
Some(PLAYER_OFF_MAP)
|
||||
} else {
|
||||
self.unlimited.then_some(PLAYER_OFF_LIMITS)
|
||||
};
|
||||
if changed {
|
||||
self.decorations_changed();
|
||||
let Some(sprite) = shown else {
|
||||
self.remove_decoration(key);
|
||||
return;
|
||||
};
|
||||
let spinning = self.dimension == "minecraft:the_nether" && game_time.is_some();
|
||||
let decoration = Decoration {
|
||||
sprite,
|
||||
x: clamp_map_coordinate(dx),
|
||||
y: clamp_map_coordinate(dz),
|
||||
rot: decoration_rotation(spinning, game_time.unwrap_or(0), y_rot),
|
||||
name,
|
||||
key,
|
||||
};
|
||||
match self.decorations.iter().position(|d| d.key == key) {
|
||||
Some(i) if self.decorations[i] == decoration => {}
|
||||
Some(i) => {
|
||||
self.decorations[i] = decoration;
|
||||
self.decorations_changed();
|
||||
}
|
||||
None => {
|
||||
self.decorations.push(decoration);
|
||||
self.decorations_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MapItemSavedData.tickCarriedBy`'s check on every holder but `who`, the
|
||||
/// player ticking: one wearing map invisibility gear has no marker.
|
||||
fn hide_invisible(&mut self, who: u32) {
|
||||
let hidden: Vec<u32> = self
|
||||
.holders
|
||||
.iter()
|
||||
.filter(|h| h.who != who && h.invisible)
|
||||
.map(|h| h.who)
|
||||
.collect();
|
||||
for w in hidden {
|
||||
self.remove_decoration(Key::Player(w));
|
||||
}
|
||||
}
|
||||
/// `MapItemSavedData.tickCarriedBy` with `placedInFrame`, for `who`, a
|
||||
/// player in the level of `frame`, the item frame holding the map.
|
||||
/// `carries` is whether their inventory holds the map (`mapMatcher`),
|
||||
/// `invisible` whether they wear map invisibility gear. Without a carried
|
||||
/// copy the player has no marker; the frame gets its own, which replaces
|
||||
/// the marker of another frame entity remembered at the same block.
|
||||
fn tick_framed(&mut self, who: u32, now: i64, carries: bool, invisible: bool, frame: Framed) {
|
||||
let h = self.holder(who);
|
||||
h.framed = now;
|
||||
h.invisible = invisible;
|
||||
if !carries {
|
||||
self.remove_decoration(Key::Player(who));
|
||||
}
|
||||
self.hide_invisible(who);
|
||||
if !self.tracking {
|
||||
return;
|
||||
}
|
||||
let pos = frame.pos;
|
||||
if let Some(old) = self.frames.iter().find(|f| f.pos == pos).copied()
|
||||
&& old.entity_id != frame.id
|
||||
{
|
||||
self.remove_decoration(Key::Frame(old.entity_id));
|
||||
}
|
||||
let rotation = direction_2d(frame.facing) * 90;
|
||||
let (x, z) = (f64::from(pos[0]), f64::from(pos[2]));
|
||||
let key = Key::Frame(frame.id);
|
||||
self.add_decoration(FRAME, Some(now), key, x, z, f64::from(rotation), None);
|
||||
let marker = MapFrame {
|
||||
pos,
|
||||
rotation,
|
||||
entity_id: frame.id,
|
||||
};
|
||||
match self.frames.iter().position(|f| f.pos == pos) {
|
||||
Some(i) if self.frames[i] == marker => {}
|
||||
Some(i) => {
|
||||
self.frames[i] = marker;
|
||||
self.dirty = true;
|
||||
}
|
||||
None => {
|
||||
self.frames.push(marker);
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MapItemSavedData.removedFromFrame`: the frame entity's marker goes,
|
||||
/// and the frame remembered at its block.
|
||||
fn removed_from_frame(&mut self, pos: [i32; 3], entity_id: i32) {
|
||||
self.remove_decoration(Key::Frame(entity_id));
|
||||
self.frames.retain(|f| f.pos != pos);
|
||||
self.dirty = true;
|
||||
}
|
||||
/// `MapItemSavedData.isTrackedCountOverLimit`: more than `limit`
|
||||
/// decorations count (`MapDecorationType.trackCount`).
|
||||
fn tracked_over_limit(&self, limit: usize) -> bool {
|
||||
self.decorations
|
||||
.iter()
|
||||
.filter(|d| tracks_count(d.sprite))
|
||||
.count()
|
||||
> limit
|
||||
}
|
||||
/// `MapItemSavedData.toggleBanner`: a banner inside the map that has no
|
||||
/// marker gets one (unless the map tracks too many), one whose marker is
|
||||
/// there as it stands loses it, and a changed banner's marker is renewed.
|
||||
/// `current` is the banner at `pos` (`MapBanner.fromWorld`); true when
|
||||
/// the markers changed.
|
||||
fn toggle_banner(&mut self, pos: [i32; 3], current: Option<MapBanner>, game_time: i64) -> bool {
|
||||
let (x_pos, z_pos) = (f64::from(pos[0]) + 0.5, f64::from(pos[2]) + 0.5);
|
||||
let scale = f64::from(1 << self.scale);
|
||||
let xd = (x_pos - f64::from(self.center[0])) / scale;
|
||||
let yd = (z_pos - f64::from(self.center[1])) / scale;
|
||||
if !(xd >= -63.0 && yd >= -63.0 && xd <= 63.0 && yd <= 63.0) {
|
||||
return false;
|
||||
}
|
||||
let Some(banner) = current else {
|
||||
return false;
|
||||
};
|
||||
if let Some(i) = self.banners.iter().position(|b| *b == banner) {
|
||||
self.banners.remove(i);
|
||||
self.remove_decoration(Key::Banner(pos));
|
||||
self.dirty = true;
|
||||
return true;
|
||||
}
|
||||
if self.tracked_over_limit(256) {
|
||||
return false;
|
||||
}
|
||||
self.banners.retain(|b| b.pos != pos);
|
||||
self.banners.push(banner.clone());
|
||||
let sprite = banner.sprite();
|
||||
self.add_decoration(
|
||||
sprite,
|
||||
Some(game_time),
|
||||
Key::Banner(pos),
|
||||
x_pos,
|
||||
z_pos,
|
||||
180.0,
|
||||
banner.name,
|
||||
);
|
||||
self.dirty = true;
|
||||
true
|
||||
}
|
||||
/// `MapItemSavedData.checkBanners`: a banner marker in the column `x`,
|
||||
/// `z` whose banner is gone or changed (`current` reads
|
||||
/// `MapBanner.fromWorld` at a position) is dropped with its decoration.
|
||||
fn check_banners(&mut self, x: i32, z: i32, current: impl Fn([i32; 3]) -> Option<MapBanner>) {
|
||||
let stale: Vec<MapBanner> = self
|
||||
.banners
|
||||
.iter()
|
||||
.filter(|b| b.pos[0] == x && b.pos[2] == z && current(b.pos).as_ref() != Some(*b))
|
||||
.cloned()
|
||||
.collect();
|
||||
for banner in stale {
|
||||
self.banners.retain(|b| *b != banner);
|
||||
self.remove_decoration(Key::Banner(banner.pos));
|
||||
self.dirty = true;
|
||||
}
|
||||
}
|
||||
/// `HoldingPlayer.nextUpdatePacket` for `who`: the changed pixels, and
|
||||
@@ -210,7 +497,7 @@ impl MapData {
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
rot: d.rot as i8,
|
||||
name: None,
|
||||
name: d.name.as_ref().map(|n| Tag::String(n.as_str().into())),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
@@ -240,8 +527,14 @@ impl MapData {
|
||||
("trackingPosition", flag(self.tracking)),
|
||||
("unlimitedTracking", flag(self.unlimited)),
|
||||
("colors", Tag::ByteArray(self.colors.clone())),
|
||||
("banners", Tag::List(self.banners.clone())),
|
||||
("frames", Tag::List(self.frames.clone())),
|
||||
(
|
||||
"banners",
|
||||
Tag::List(self.banners.iter().map(MapBanner::to_nbt).collect()),
|
||||
),
|
||||
(
|
||||
"frames",
|
||||
Tag::List(self.frames.iter().copied().map(MapFrame::to_nbt).collect()),
|
||||
),
|
||||
]),
|
||||
),
|
||||
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
|
||||
@@ -267,7 +560,7 @@ impl MapData {
|
||||
Some(Tag::List(l)) => l.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Some(Self {
|
||||
let mut map = Self {
|
||||
center: [int("xCenter")?, int("zCenter")?],
|
||||
scale: int("scale").unwrap_or(0).clamp(0, 4) as u8,
|
||||
dimension,
|
||||
@@ -276,12 +569,29 @@ impl MapData {
|
||||
unlimited: int("unlimitedTracking").unwrap_or(0) != 0,
|
||||
colors,
|
||||
decorations: Vec::new(),
|
||||
banners: list("banners"),
|
||||
frames: list("frames"),
|
||||
banners: list("banners")
|
||||
.iter()
|
||||
.filter_map(MapBanner::from_nbt)
|
||||
.collect(),
|
||||
frames: list("frames")
|
||||
.iter()
|
||||
.filter_map(MapFrame::from_nbt)
|
||||
.collect(),
|
||||
holders: Vec::new(),
|
||||
dirty: false,
|
||||
texture_dirty: true,
|
||||
})
|
||||
};
|
||||
// The constructor adds each marker without a level: no Nether spin.
|
||||
for b in map.banners.clone() {
|
||||
let (x, z) = (f64::from(b.pos[0]), f64::from(b.pos[2]));
|
||||
map.add_decoration(b.sprite(), None, Key::Banner(b.pos), x, z, 180.0, b.name);
|
||||
}
|
||||
for f in map.frames.clone() {
|
||||
let (x, z) = (f64::from(f.pos[0]), f64::from(f.pos[2]));
|
||||
let rotation = f64::from(f.rotation);
|
||||
map.add_decoration(FRAME, None, Key::Frame(f.entity_id), x, z, rotation, None);
|
||||
}
|
||||
Some(map)
|
||||
}
|
||||
}
|
||||
pub(super) struct Maps {
|
||||
@@ -319,7 +629,7 @@ pub(super) fn map_id(stack: &ItemStack) -> Option<i32> {
|
||||
stack.data().and_then(|d| d.map_id)
|
||||
}
|
||||
/// `MapDecorationTypes` Horizon draws: (type, sprite).
|
||||
const DECORATION_TYPES: [(&str, &str); 24] = [
|
||||
const DECORATION_TYPES: [(&str, &str); 40] = [
|
||||
("player", "map/decorations/player"),
|
||||
("frame", "map/decorations/frame"),
|
||||
("red_marker", "map/decorations/red_marker"),
|
||||
@@ -330,6 +640,22 @@ const DECORATION_TYPES: [(&str, &str); 24] = [
|
||||
("player_off_limits", "map/decorations/player_off_limits"),
|
||||
("mansion", "map/decorations/woodland_mansion"),
|
||||
("monument", "map/decorations/ocean_monument"),
|
||||
("banner_white", "map/decorations/white_banner"),
|
||||
("banner_orange", "map/decorations/orange_banner"),
|
||||
("banner_magenta", "map/decorations/magenta_banner"),
|
||||
("banner_light_blue", "map/decorations/light_blue_banner"),
|
||||
("banner_yellow", "map/decorations/yellow_banner"),
|
||||
("banner_lime", "map/decorations/lime_banner"),
|
||||
("banner_pink", "map/decorations/pink_banner"),
|
||||
("banner_gray", "map/decorations/gray_banner"),
|
||||
("banner_light_gray", "map/decorations/light_gray_banner"),
|
||||
("banner_cyan", "map/decorations/cyan_banner"),
|
||||
("banner_purple", "map/decorations/purple_banner"),
|
||||
("banner_blue", "map/decorations/blue_banner"),
|
||||
("banner_brown", "map/decorations/brown_banner"),
|
||||
("banner_green", "map/decorations/green_banner"),
|
||||
("banner_red", "map/decorations/red_banner"),
|
||||
("banner_black", "map/decorations/black_banner"),
|
||||
("red_x", "map/decorations/red_x"),
|
||||
("village_desert", "map/decorations/desert_village"),
|
||||
("village_plains", "map/decorations/plains_village"),
|
||||
@@ -363,6 +689,22 @@ pub(super) fn shown_on_frame(sprite: &str) -> bool {
|
||||
Some("player" | "red_marker" | "blue_marker" | "player_off_map" | "player_off_limits")
|
||||
)
|
||||
}
|
||||
/// `MapDecorationType.trackCount`: the players' arrows, the markers, the
|
||||
/// frames and the banners count toward a map's limit, the structures do not.
|
||||
fn tracks_count(sprite: &str) -> bool {
|
||||
decoration_type(sprite).is_some_and(|t| {
|
||||
t.starts_with("banner_")
|
||||
|| matches!(
|
||||
t,
|
||||
"player"
|
||||
| "frame"
|
||||
| "red_marker"
|
||||
| "blue_marker"
|
||||
| "player_off_map"
|
||||
| "player_off_limits"
|
||||
)
|
||||
})
|
||||
}
|
||||
/// The sprite of a decoration type that a `minecraft:map_decorations`
|
||||
/// entry or a server's packet names.
|
||||
fn decoration_sprite(id: &str) -> Option<&'static str> {
|
||||
@@ -498,7 +840,11 @@ impl Game<'_> {
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
rot: (d.rot & 15) as u8,
|
||||
holder: None,
|
||||
name: d
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|t| horizon_client::entity_nbt::name_text(t).plain()),
|
||||
key: Key::Item,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -577,7 +923,9 @@ impl Game<'_> {
|
||||
.end
|
||||
.locator()
|
||||
.filter(|_| !names.is_empty())
|
||||
.and_then(|t| t.locate(&names, from, map.search_radius));
|
||||
.and_then(|t| {
|
||||
t.locate_for_map(&names, from, map.search_radius, map.skip_existing_chunks)
|
||||
});
|
||||
let Some((_, at)) = found else {
|
||||
return false;
|
||||
};
|
||||
@@ -753,6 +1101,45 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `ItemFrame.removeFramedMap`: the map `frame` holds forgets the frame.
|
||||
pub(super) fn remove_framed_map(&mut self, frame: &horizon_client::entities::ItemFrame) {
|
||||
let Some(id) = frame.item.as_ref().and_then(map_id) else {
|
||||
return;
|
||||
};
|
||||
if let Some(m) = self.map(id) {
|
||||
m.removed_from_frame(frame.pos, frame.id as i32);
|
||||
}
|
||||
}
|
||||
/// `MapItem.useOn`: a map used on a banner toggles that banner's marker
|
||||
/// on the map; when the map's data says no (`toggleBanner` fails) the use
|
||||
/// ends without a swing, and nothing else is done with the map either way.
|
||||
pub(super) fn use_map_on_banner(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
let Some(id) = map_id(stack) else {
|
||||
return false;
|
||||
};
|
||||
// ItemStack.useOn passes in adventure mode (its `can_place_on`
|
||||
// exception is not ported).
|
||||
if !self.may_build() {
|
||||
return false;
|
||||
}
|
||||
let [x, y, z] = hit.pos;
|
||||
let block = self.res.facts().get(self.world.block(x, y, z)).name.clone();
|
||||
if !self.block_in_tag("minecraft:banners", &block) {
|
||||
return false;
|
||||
}
|
||||
let current = self.map_banner_at(hit.pos);
|
||||
let game_time = self.world.game_time;
|
||||
let failed = self
|
||||
.map(id)
|
||||
.is_some_and(|m| !m.toggle_banner(hit.pos, current, game_time));
|
||||
if failed {
|
||||
return true;
|
||||
}
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
|
||||
self.stats.add("used", &stack.item, 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `CompassItem.useOn`: a compass used on a lodestone locks onto it (a
|
||||
/// single one in survival changes in place, else one is split off).
|
||||
pub(super) fn use_lodestone_compass(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
|
||||
@@ -848,6 +1235,19 @@ impl Game<'_> {
|
||||
self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40);
|
||||
}
|
||||
}
|
||||
/// `MapItemSavedData.hasMapInvisibilityItemEquipped`: a piece of
|
||||
/// `#map_invisibility_equipment` (a carved pumpkin) in an armor slot; the
|
||||
/// hands do not count.
|
||||
fn map_invisibility_worn(&self, worn: &[Option<ItemStack>]) -> bool {
|
||||
let tag = self
|
||||
.res
|
||||
.items()
|
||||
.item_tags
|
||||
.get("minecraft:map_invisibility_equipment");
|
||||
worn.iter()
|
||||
.flatten()
|
||||
.any(|s| tag.is_some_and(|t| t.contains(&*s.item)))
|
||||
}
|
||||
/// Held maps update; every carried map tracks the player marker.
|
||||
pub(super) fn tick_maps(&mut self) {
|
||||
let Some(p) = self.player.as_ref().map(|p| p.position) else {
|
||||
@@ -879,48 +1279,30 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let (yaw, dim) = (self.yaw, self.dimension_id());
|
||||
let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time);
|
||||
let invisible = self.map_invisibility_worn(&self.armor);
|
||||
// The guest whose turn it is, else the host.
|
||||
let me = self.lan_acting.unwrap_or(HOST);
|
||||
for (id, targets) in carried {
|
||||
let Some(m) = self.map(id) else { continue };
|
||||
m.holder(me).carried = game_time;
|
||||
let h = m.holder(me);
|
||||
h.carried = game_time;
|
||||
h.invisible = invisible;
|
||||
m.drop_holders(game_time);
|
||||
// tickCarriedBy: the player marker while tracking in this dimension.
|
||||
let mut marker = None;
|
||||
if m.tracking && m.dimension == dim {
|
||||
let s = f64::from(1 << m.scale);
|
||||
let dx = ((p.x - f64::from(m.center[0])) / s) as f32;
|
||||
let dz = ((p.z - f64::from(m.center[1])) / s) as f32;
|
||||
let inside = (-63.0..=63.0).contains(&dx) && (-63.0..=63.0).contains(&dz);
|
||||
let clamp = |d: f32| {
|
||||
if d <= -63.0 {
|
||||
-128
|
||||
} else if d >= 63.0 {
|
||||
127
|
||||
} else {
|
||||
(d * 2.0 + 0.5) as i8
|
||||
}
|
||||
};
|
||||
// calculateRotation: the arrow spins in the Nether.
|
||||
let rot = decoration_rotation(nether, game_time, f64::from(yaw));
|
||||
let sprite = if inside {
|
||||
Some("map/decorations/player")
|
||||
} else if dx.abs() < 320.0 && dz.abs() < 320.0 {
|
||||
Some("map/decorations/player_off_map")
|
||||
} else if m.unlimited {
|
||||
Some("map/decorations/player_off_limits")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
marker = sprite.map(|sprite| Decoration {
|
||||
sprite,
|
||||
x: clamp(dx),
|
||||
y: clamp(dz),
|
||||
rot,
|
||||
holder: Some(me),
|
||||
});
|
||||
m.add_decoration(
|
||||
PLAYER,
|
||||
Some(game_time),
|
||||
Key::Player(me),
|
||||
p.x,
|
||||
p.z,
|
||||
f64::from(yaw),
|
||||
None,
|
||||
);
|
||||
} else if m.decorations.iter().any(|d| d.key == Key::Player(me)) {
|
||||
m.remove_decoration(Key::Player(me));
|
||||
}
|
||||
m.set_marker(me, marker);
|
||||
m.hide_invisible(me);
|
||||
// The item's MAP_DECORATIONS (an explorer map's target), added
|
||||
// after the players as addDecoration would.
|
||||
let s = (1 << m.scale) as f32;
|
||||
@@ -937,7 +1319,8 @@ impl Game<'_> {
|
||||
x: clamp_map_coordinate(dx),
|
||||
y: clamp_map_coordinate(dz),
|
||||
rot: decoration_rotation(nether, game_time, f64::from(rotation)),
|
||||
holder: None,
|
||||
name: None,
|
||||
key: Key::Item,
|
||||
};
|
||||
if !items.contains(&d) {
|
||||
items.push(d);
|
||||
@@ -946,10 +1329,10 @@ impl Game<'_> {
|
||||
if !m
|
||||
.decorations
|
||||
.iter()
|
||||
.filter(|d| d.holder.is_none())
|
||||
.filter(|d| d.key == Key::Item)
|
||||
.eq(items.iter())
|
||||
{
|
||||
m.decorations.retain(|d| d.holder.is_some());
|
||||
m.decorations.retain(|d| d.key != Key::Item);
|
||||
m.decorations.extend(items);
|
||||
m.decorations_changed();
|
||||
}
|
||||
@@ -993,14 +1376,30 @@ impl Game<'_> {
|
||||
.collect();
|
||||
let now = self.world.game_time;
|
||||
if now % 10 == 0 && host.hub.here(g) {
|
||||
let framed: Vec<i32> = self
|
||||
let framed: Vec<(i32, std::sync::Arc<str>, Framed)> = self
|
||||
.item_frames
|
||||
.iter()
|
||||
.filter_map(|f| f.item.as_ref().and_then(map_id))
|
||||
.filter_map(|f| {
|
||||
let item = f.item.as_ref()?;
|
||||
let frame = Framed {
|
||||
pos: f.pos,
|
||||
facing: f.facing,
|
||||
id: f.id as i32,
|
||||
};
|
||||
Some((map_id(item)?, item.item.clone(), frame))
|
||||
})
|
||||
.collect();
|
||||
for id in framed {
|
||||
let invisible = self.map_invisibility_worn(&g.armor);
|
||||
for (id, item, frame) in framed {
|
||||
// tickCarriedBy's mapMatcher: the same item with the same map id.
|
||||
let carries = g
|
||||
.inventory
|
||||
.iter()
|
||||
.chain(std::iter::once(&g.offhand))
|
||||
.flatten()
|
||||
.any(|s| s.item == item && map_id(s) == Some(id));
|
||||
if let Some(m) = self.map(id) {
|
||||
m.holder(conn).framed = now;
|
||||
m.tick_framed(conn, now, carries, invisible, frame);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
@@ -1026,6 +1425,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `MapItemSavedData.checkBanners` on map `id` for the column `x`, `z`
|
||||
/// that `MapItem.update` is reading.
|
||||
fn check_banners(&mut self, id: i32, x: i32, z: i32) {
|
||||
let (world, res) = (&self.world, &self.res);
|
||||
let Some(m) = self.maps.data.get_mut(&id) else {
|
||||
return;
|
||||
};
|
||||
if m.banners.is_empty() {
|
||||
return;
|
||||
}
|
||||
m.check_banners(x, z, |pos| {
|
||||
let state = world.block(pos[0], pos[1], pos[2]);
|
||||
super::banners::map_banner(
|
||||
pos,
|
||||
world.block_entities.get(&(pos[0], pos[1], pos[2])),
|
||||
&res.facts().get(state).name,
|
||||
)
|
||||
});
|
||||
}
|
||||
/// `MapItem.update` for the player holding map `id`.
|
||||
fn update_map(&mut self, id: i32) {
|
||||
let dim = self.dimension_id();
|
||||
@@ -1125,6 +1543,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.check_banners(id, x, z);
|
||||
height += f64::from(y) / f64::from(scale * scale);
|
||||
add(color, 1);
|
||||
}
|
||||
|
||||
@@ -705,7 +705,11 @@ impl Game<'_> {
|
||||
self.carts.retain(|x| x.id != c.id);
|
||||
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
|
||||
let interaction = self.destroy_type("tnt_explosion_drop_decay");
|
||||
// MinecartTNT.isPrimed (fuse > -1): a cart that ran into a wall or
|
||||
// fell unprimed blows rails up like any blast.
|
||||
self.combat.rail_proof_blast = c.fuse >= 0;
|
||||
let _ = self.with_hive_emptying_blast(|g| g.server_explode(at, power, interaction, false));
|
||||
self.combat.rail_proof_blast = false;
|
||||
}
|
||||
/// `AbstractMinecart.push` from each player walking into a cart
|
||||
/// (`LivingEntity.pushEntities`): the window's player and every server
|
||||
@@ -1108,24 +1112,7 @@ impl Game<'_> {
|
||||
self.riding = Some(self.carts[i].id);
|
||||
}
|
||||
// AbstractMinecartContainer.interact: no sneak check.
|
||||
CartKind::Chest | CartKind::Hopper => {
|
||||
self.unpack_cart_loot(i);
|
||||
let c = self.carts[i].clone();
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
self.world
|
||||
.block_entities
|
||||
.insert(key, BlockEntity::Chest(c.items.clone()));
|
||||
self.container = Some(key);
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
if c.kind == CartKind::Chest {
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:block.chest.open", Some(at), 0.5, pitch);
|
||||
}
|
||||
}
|
||||
CartKind::Chest | CartKind::Hopper => self.open_cart_container(i),
|
||||
CartKind::Furnace => {
|
||||
let fuel = held
|
||||
.as_ref()
|
||||
@@ -1162,6 +1149,32 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `AbstractMinecartContainer.interact`: the menu opens over the cart's
|
||||
/// own slots, kept under a placeholder key until it closes
|
||||
/// (`store_cart_items`/`close_cart_container`).
|
||||
pub(super) fn open_cart_container(&mut self, i: usize) {
|
||||
self.unpack_cart_loot(i);
|
||||
let c = self.carts[i].clone();
|
||||
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
|
||||
self.world
|
||||
.block_entities
|
||||
.insert(key, BlockEntity::Chest(c.items.clone()));
|
||||
self.container = Some(key);
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
if c.kind == CartKind::Chest {
|
||||
// `MinecartChest.interact` (which overrides
|
||||
// `AbstractMinecartContainer.interact`): a successful open fires
|
||||
// CONTAINER_OPEN with the player and angers the piglins that see
|
||||
// it. `MinecartHopper` does not override it, so a hopper minecart
|
||||
// fires neither. Vanilla plays no open sound for a chest minecart.
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
let ctx = self.player_context(None);
|
||||
self.post_game_event("minecraft:container_open", at, ctx);
|
||||
self.anger_piglins(true);
|
||||
}
|
||||
}
|
||||
/// Closing the cart's container open under `key` copies the items back.
|
||||
pub(super) fn close_cart_container(&mut self, key: (i32, i32, i32)) {
|
||||
if key.1 != CART_KEY_Y {
|
||||
@@ -1169,14 +1182,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.store_cart_items(key);
|
||||
self.world.block_entities.remove(&key);
|
||||
if let Some(c) = self.carts.iter().find(|c| c.id as i32 == key.2)
|
||||
&& c.kind == CartKind::Chest
|
||||
{
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
let pitch = self.world.random.next_float() * 0.1 + 0.9;
|
||||
self.audio
|
||||
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
|
||||
}
|
||||
}
|
||||
/// The menu of a chest or hopper minecart or a chest boat works on the
|
||||
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
|
||||
|
||||
@@ -431,10 +431,10 @@ impl Game<'_> {
|
||||
// The stack the equipment packet (or the mob's own AI) left:
|
||||
// its components are what the item model reads, a loaded
|
||||
// crossbow's `charged_projectiles` included.
|
||||
mob.extra
|
||||
.main_hand_stack()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ItemStack::new(item, 1))
|
||||
let Some(stack) = render::held_item_stack(&mob.extra, off_hand) else {
|
||||
continue;
|
||||
};
|
||||
stack
|
||||
};
|
||||
if !local_player && horizon_client::mobs::gear::enchanted(mob, slot) {
|
||||
// The gear's enchantments are not tracked, only that it has some.
|
||||
|
||||
@@ -128,6 +128,16 @@ impl Game<'_> {
|
||||
self.enchant_single(id, MAIN, "minecraft:piercing", 1, "minecraft:crossbow");
|
||||
}
|
||||
}
|
||||
/// `Zombie` / `AbstractSkeleton` halloween pumpkin after spawn gear.
|
||||
pub(super) fn halloween_spawn_head(&mut self, id: u32) {
|
||||
let halloween = horizon_client::mobs::finalize::is_halloween();
|
||||
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
|
||||
return;
|
||||
};
|
||||
let mut random = self.mobs[i].random.clone();
|
||||
horizon_client::mobs::finalize::halloween_head(&mut self.mobs[i], &mut random, halloween);
|
||||
self.mobs[i].random = random;
|
||||
}
|
||||
fn enchant_slot(&mut self, id: u32, slot: usize, chance: f32, special: f32) {
|
||||
let Some(item) = self
|
||||
.mobs
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! `Mob.aiStep`'s looting loop: a mob with `canPickUpLoot` takes what lies
|
||||
//! within its box grown by `getPickupReach` (1, 0, 1) and wears or holds it
|
||||
//! when it beats what it has (`equipItemIfPossible`), dropping the replaced
|
||||
//! stack by its drop chance. The species whose `pickUpItem` does something
|
||||
//! else keep their own loops (`pandas.rs`, `piglins.rs`, `villagers.rs`,
|
||||
//! `allays.rs`, `sulfur_cubes.rs`).
|
||||
use super::{
|
||||
Game,
|
||||
pickups::{Collector, Taken},
|
||||
};
|
||||
use horizon_client::{
|
||||
entities::ItemEntity,
|
||||
items::ItemStack,
|
||||
mobs::{
|
||||
Mob, Species,
|
||||
equip::{self, Facts, ItemTags, Slot},
|
||||
finalize::{
|
||||
self, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
|
||||
stored_drop_chance,
|
||||
},
|
||||
},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Mob.aiStep`'s looting block, once per mob per tick.
|
||||
pub(super) fn tick_mob_looting(&mut self) {
|
||||
if self.dropped.is_empty() || !self.ui.rules.bool("mob_griefing") {
|
||||
return;
|
||||
}
|
||||
for i in 0..self.mobs.len() {
|
||||
let m = &self.mobs[i];
|
||||
if !m.can_pick_up_loot() || !m.alive() || m.removed {
|
||||
continue;
|
||||
}
|
||||
// `Mob.ITEM_PICKUP_REACH`.
|
||||
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
|
||||
let (species, baby, passenger) = (m.species(), m.is_baby(), m.extra.passenger);
|
||||
let mut j = 0;
|
||||
while j < self.dropped.len() {
|
||||
let e = &self.dropped[j];
|
||||
if e.pickup_delay > 0 || !reach.intersects(e.bounds()) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
let stack = e.stack.clone();
|
||||
let item = &stack.item;
|
||||
let tags = ItemTags {
|
||||
spear: self.items().item_in_tag("minecraft:spears", item),
|
||||
egg: self.items().item_in_tag("minecraft:eggs", item),
|
||||
disliked_weapon: self
|
||||
.items()
|
||||
.item_in_tag("minecraft:wither_skeleton_disliked_weapons", item),
|
||||
};
|
||||
if !equip::wants_to_pick_up(species, baby, passenger, item, tags) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
// `Mob.pickUpItem`: nothing leaves the ground what it cannot use.
|
||||
let Some(taken) = self.equip_item_if_possible(i, &stack) else {
|
||||
j += 1;
|
||||
continue;
|
||||
};
|
||||
let before = self.dropped[j].clone();
|
||||
let id = self.mobs[i].id;
|
||||
self.add_pickup(Taken::Item(Box::new(before.clone())), Collector::Mob(id));
|
||||
self.mob_item_pickup_trigger(i, &before);
|
||||
let e = &mut self.dropped[j];
|
||||
e.stack.count -= taken;
|
||||
if e.stack.count == 0 {
|
||||
self.dropped.remove(j);
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Mob.equipItemIfPossible`: what `slot` would take of `stack`, and how
|
||||
/// many items that takes. `None` when the mob has no use for it.
|
||||
fn equip_item_if_possible(&mut self, index: usize, stack: &ItemStack) -> Option<u32> {
|
||||
let item = &*stack.item;
|
||||
let equip_slot = self.items().get(item).and_then(|i| i.equip_slot.clone());
|
||||
let mut slot = equip::slot_for(equip_slot.as_deref())?;
|
||||
let mut replace = self.can_replace(index, stack, slot);
|
||||
// An armour piece that beats nothing it is worn with falls back to an
|
||||
// empty main hand.
|
||||
if slot.is_armor() && !replace {
|
||||
slot = Slot::MainHand;
|
||||
replace = self.mobs[index].extra.main_hand.is_none();
|
||||
}
|
||||
if !replace {
|
||||
return None;
|
||||
}
|
||||
// shortcut: a mob's equipment is one item per slot here, so a hand
|
||||
// takes one item of a stack where vanilla takes the whole stack;
|
||||
// upgrade with a per-slot count in `Extra`.
|
||||
let taken = 1;
|
||||
let m = &mut self.mobs[index];
|
||||
let old = finalize::item(m, slot.index());
|
||||
let chance = stored_drop_chance(m, slot.index());
|
||||
let old_enchantments = slot_enchantments(m, slot.index());
|
||||
// `DropChances.byEquipment` against the roll the equip makes.
|
||||
let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance;
|
||||
set_item(m, slot.index(), item);
|
||||
// The stack's enchantments come with it: a Sharpness V sword stays sharp.
|
||||
set_slot_enchantments(m, slot.index(), stack.enchantments().to_vec());
|
||||
// `setItemSlotAndDropWhenKilled`, then `persistenceRequired`.
|
||||
set_drop_chance(m, slot.index(), 2.0);
|
||||
m.persistent = true;
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if drop_old && let Some(old) = old {
|
||||
let mut dropped = ItemStack::new(&old, 1);
|
||||
dropped.set_crafting_enchantments(old_enchantments);
|
||||
self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10);
|
||||
}
|
||||
Some(taken)
|
||||
}
|
||||
|
||||
/// `Mob.canReplaceCurrentItem` for what `m` wears or holds in `slot`.
|
||||
fn can_replace(&self, index: usize, stack: &ItemStack, slot: Slot) -> bool {
|
||||
let m = &self.mobs[index];
|
||||
let Some(current) = finalize::item(m, slot.index()) else {
|
||||
return true;
|
||||
};
|
||||
let species = m.species();
|
||||
// `Drowned.canReplaceCurrentItem` never parts with a nautilus shell.
|
||||
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
|
||||
return false;
|
||||
}
|
||||
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
|
||||
let new = Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
enchantments: stack.enchantments().len(),
|
||||
damage: stack.damage as i32,
|
||||
custom_name: stack.custom_name().is_some(),
|
||||
// `EnchantmentEffectComponents.PREVENT_ARMOR_CHANGE` is the Curse of
|
||||
// Binding's, the only enchantment that carries one.
|
||||
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
|
||||
};
|
||||
let worn_enchantments = slot_enchantments(m, slot.index());
|
||||
let (armor, toughness, attack) = self.attributes(m, ¤t, slot);
|
||||
// shortcut: what a mob already wears keeps no damage value or name here
|
||||
// (`Extra` records neither), so a worn piece always compares as fresh;
|
||||
// upgrade with a stack per equipment slot.
|
||||
let worn = Facts {
|
||||
armor,
|
||||
armor_toughness: toughness,
|
||||
attack_damage: attack,
|
||||
enchantments: worn_enchantments.len(),
|
||||
damage: 0,
|
||||
custom_name: false,
|
||||
prevents_armor_change: worn_enchantments
|
||||
.iter()
|
||||
.any(|(id, level)| &**id == "minecraft:binding_curse" && *level > 0),
|
||||
};
|
||||
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
|
||||
(
|
||||
self.items().item_in_tag(tag, &stack.item),
|
||||
self.items().item_in_tag(tag, ¤t),
|
||||
)
|
||||
});
|
||||
equip::can_replace_current_item(&new, Some(&worn), slot, preferred)
|
||||
}
|
||||
|
||||
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
|
||||
/// attribute starts from the mob's own value. (armour, toughness, damage)
|
||||
fn attributes(&self, m: &Mob, id: &str, slot: Slot) -> (f64, f64, f64) {
|
||||
let empty: &[(String, String, f64, String, String)] = &[];
|
||||
let modifiers = self
|
||||
.items()
|
||||
.get(id)
|
||||
.map_or(empty, |i| i.modifiers.as_slice());
|
||||
(
|
||||
equip::attribute_with(modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
|
||||
equip::attribute_with(
|
||||
modifiers,
|
||||
"minecraft:armor_toughness",
|
||||
f64::from(m.kind.armor_toughness),
|
||||
slot,
|
||||
),
|
||||
equip::attribute_with(
|
||||
modifiers,
|
||||
"minecraft:attack_damage",
|
||||
f64::from(m.attrs.attack_damage),
|
||||
slot,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// `LivingEntity.onItemPickup`: the advancement of whoever threw the item,
|
||||
/// whichever mob took it.
|
||||
pub(super) fn mob_item_pickup_trigger(&mut self, index: usize, e: &ItemEntity) {
|
||||
let Some(owner) = e.thrower else {
|
||||
return;
|
||||
};
|
||||
let stack = e.stack.clone();
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.for_each_player(|g| {
|
||||
if g.acting_uuid() == owner {
|
||||
g.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", &stack, &target);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,16 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 41 adds entities joining a level (`native_entity_join_level`), for
|
||||
//! item entities and experience orbs so far.
|
||||
//! API 44 adds the server starting and stopping (`native_server_about_to_start`,
|
||||
//! `native_server_stopping`).
|
||||
//!
|
||||
//! API 43 adds the server level's random (`native_level_random`).
|
||||
//!
|
||||
//! API 42 adds the item entities near a block (`native_item_entities`,
|
||||
//! `native_set_item_entity`).
|
||||
//!
|
||||
//! API 41 adds bundles' data files: `horizon_client::native_data!` compiles a file of a
|
||||
//! bundle's `data/` folder in, and `native_built_data` is the folder its post-build step wrote.
|
||||
//!
|
||||
//! API 40 adds mod messages between a mod's server and client halves
|
||||
//! (`native_net_from_client`, `native_net_from_server`, and
|
||||
@@ -194,7 +202,7 @@ pub(super) use horizon_world::level::flags;
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 41;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 44;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// An authoritative server committed a changed state through `Level.setBlock`.
|
||||
@@ -661,6 +669,26 @@ impl Game<'_> {
|
||||
) {
|
||||
let _ = (context, phase, error_text);
|
||||
}
|
||||
/// The server is about to open its worlds: nothing of the world is
|
||||
/// loaded yet (NeoForge's `ServerAboutToStartEvent`, "called before the
|
||||
/// server begins loading anything"). Vanilla's `MinecraftServer.loadLevel`
|
||||
/// start. Server-side: the single player server thread once Play is
|
||||
/// pressed, a dedicated server once its settings are read. Each is
|
||||
/// followed by `native_world_lifecycle`'s `WorldOpenReady` or
|
||||
/// `WorldOpenFailed`. `dedicated` tells a dedicated server from the single
|
||||
/// player one. API 44.
|
||||
pub(super) fn native_server_about_to_start(&mut self, dedicated: bool) {
|
||||
let _ = dedicated;
|
||||
}
|
||||
/// The server begins an orderly stop (NeoForge's `ServerStoppingEvent`):
|
||||
/// the start of vanilla's `MinecraftServer.stopServer`, with the worlds
|
||||
/// still loaded and the players still connected and not yet saved, so a
|
||||
/// mod can save its own data or tell them. `native_world_lifecycle`'s
|
||||
/// `WorldCloseFinal` follows once the worlds are saved. A server whose loop
|
||||
/// fails stops without this (`WorldCloseFailed` reports it). API 44.
|
||||
pub(super) fn native_server_stopping(&mut self, dedicated: bool) {
|
||||
let _ = dedicated;
|
||||
}
|
||||
/// The authoritative server tick's phases: `ServerTickStart` before any
|
||||
/// level is ticked (NeoForge's `ServerTickEvent.Pre`), `ServerTickEnd`
|
||||
/// after the last (`.Post`). Read blocks with `native_server_block`, by
|
||||
@@ -725,7 +753,8 @@ impl Game<'_> {
|
||||
/// A living player's server tick ends (`ServerPlayer.tick`): once a tick
|
||||
/// for every player in the world, after their digging, item use,
|
||||
/// cooldowns, statistics and advancements. The helpers read that player.
|
||||
/// API 31.
|
||||
/// Players still tick under `/tick freeze`; a dead player doesn't, nor
|
||||
/// does one killed earlier in the same tick. API 31.
|
||||
pub(super) fn native_player_tick(&mut self) {}
|
||||
/// The player-health HUD rows, once a frame the HUD draws them
|
||||
/// (`HealthHud::layout`, not under F1): `inputs` carries the health,
|
||||
@@ -1101,7 +1130,7 @@ impl Game<'_> {
|
||||
/// an arrival through a portal). `entity` is its ID and `kind` its type
|
||||
/// (`minecraft:item`). It is already in the level's lists, so helpers that
|
||||
/// take an entity ID find it. Set `canceled` to keep it out: it is taken
|
||||
/// back out as if never added, and a `/summon` of it fails. API 41 tells
|
||||
/// back out as if never added, and a `/summon` of it fails. @API:entity_join_level@ tells
|
||||
/// item entities and experience orbs; later APIs add other kinds, so check
|
||||
/// `kind`. Server-side.
|
||||
pub(super) fn native_entity_join_level(
|
||||
@@ -1122,7 +1151,8 @@ impl Game<'_> {
|
||||
let _ = (player, entity, kind);
|
||||
}
|
||||
/// `player` threw `count` of `item` (`Player.drop`: the drop key, a
|
||||
/// menu's throw...). It only hears. Server-side. API 40.
|
||||
/// menu's throw...), not what scatters when they die. It only hears.
|
||||
/// Server-side. API 40.
|
||||
pub(super) fn native_item_dropped(&mut self, player: u128, item: &str, count: u32) {
|
||||
let _ = (player, item, count);
|
||||
}
|
||||
@@ -1160,7 +1190,7 @@ impl Game<'_> {
|
||||
let _ = (slot, selected);
|
||||
}
|
||||
/// A player's equipment slot `slot` (`head`, `chest`, `legs`, `feet`,
|
||||
/// `offhand`, and `mainhand` on a swap of the hands) took a different
|
||||
/// `offhand`, and `mainhand` on a swap of the hands or `/item`) took a different
|
||||
/// item, `new` (none when it was emptied): `LivingEntity.onEquipItem`,
|
||||
/// before the equip sound. It only hears. Never for a spectator.
|
||||
/// Server-side, for every player of the world (the helpers read the
|
||||
@@ -1274,9 +1304,10 @@ impl GameLevel<'_> {
|
||||
/// `flags::UPDATE_ALL` does both. Horizon sends every write to players
|
||||
/// whatever the flags: `UPDATE_CLIENTS`, `UPDATE_INVISIBLE` and
|
||||
/// `UPDATE_IMMEDIATE` change nothing yet. Any loaded position, not only
|
||||
/// the hook's; false off an authoritative server, when the block has no
|
||||
/// such property or value, or `pos` is not loaded; true without a write
|
||||
/// when the property already has `value`. Elsewhere,
|
||||
/// the hook's; true when the block changed, false off an authoritative
|
||||
/// server, when the block has no such property or value, `pos` is not
|
||||
/// loaded, or the property already has `value` (no write, as
|
||||
/// `Level.setBlock` returns false for an unchanged state). Elsewhere,
|
||||
/// `Game::native_set_block_property_with` does the same. API 33.
|
||||
pub(super) fn native_set_block_property(
|
||||
&mut self,
|
||||
@@ -1291,12 +1322,9 @@ impl GameLevel<'_> {
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let state = self.block_state(at);
|
||||
let Some(id) = with_property(self.registry, state, key, value) else {
|
||||
let Some(id) = property_change(self.registry, state, key, value) else {
|
||||
return false;
|
||||
};
|
||||
if id == state {
|
||||
return true;
|
||||
}
|
||||
let b = self.behaviours;
|
||||
b.set_block(self, ticks, at, id, flags)
|
||||
}
|
||||
@@ -1399,10 +1427,94 @@ impl Game<'_> {
|
||||
let _ = self.process_level_events();
|
||||
changed
|
||||
}
|
||||
/// The random of the server level `dimension`: vanilla's `Level.random`
|
||||
/// (`level.getRandom()`), the one its explosions, block drops and other
|
||||
/// level draws use, so a mod's draws move vanilla's later ones as a Java
|
||||
/// mod's do. Draw with `horizon_core::random::RandomSource` (`next_int_bound`,
|
||||
/// `next_float`...). `None` on a client of a server, and for a level not
|
||||
/// open: the Nether and the End open when someone first goes there and
|
||||
/// close after 300 ticks with nobody in them (`close_idle_levels`). API 43.
|
||||
pub(super) fn native_level_random(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
) -> Option<&mut horizon_core::random::LegacyRandom> {
|
||||
if self.serving.is_none() && self.options.dedicated.is_none() {
|
||||
return None;
|
||||
}
|
||||
let dim = match dimension {
|
||||
"minecraft:overworld" => super::Dim::Overworld,
|
||||
"minecraft:the_nether" => super::Dim::Nether,
|
||||
"minecraft:the_end" => super::Dim::End,
|
||||
_ => return None,
|
||||
};
|
||||
if dim == self.dim {
|
||||
Some(&mut self.world.random)
|
||||
} else {
|
||||
self.levels
|
||||
.parked_world_mut(dim)
|
||||
.map(|world| &mut world.random)
|
||||
}
|
||||
}
|
||||
/// The item entities of the server level `dimension` whose box meets
|
||||
/// the box `min`..`max`, in the level's order: vanilla's
|
||||
/// `Level.getEntitiesOfClass(ItemEntity.class, box, ENTITY_STILL_ALIVE)`, as a
|
||||
/// hopper's `getItemsAtAndAbove` asks for its block and the one above
|
||||
/// (`[x, y, z]`..`[x + 1, y + 2, z + 1]`). Empty for a level not in place
|
||||
/// (as `native_set_block`) and on a client of a server. API 42.
|
||||
pub(super) fn native_item_entities(
|
||||
&self,
|
||||
dimension: &str,
|
||||
min: [f64; 3],
|
||||
max: [f64; 3],
|
||||
) -> Vec<NativeItemEntity> {
|
||||
if (self.serving.is_none() && self.options.dedicated.is_none())
|
||||
|| dimension != self.dim.id()
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
self.dropped
|
||||
.iter()
|
||||
.filter(|e| e.stack.count > 0 && box_meets(&e.bounds(), min, max))
|
||||
.map(|e| NativeItemEntity {
|
||||
id: e.id,
|
||||
position: [e.position.x, e.position.y, e.position.z],
|
||||
stack: e.stack.clone(),
|
||||
pickup_delay: e.pickup_delay,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Sets the stack of item entity `id` in the server level `dimension`, as
|
||||
/// a hopper's `addItem` leaves what it couldn't take (`ItemEntity.setItem`);
|
||||
/// an empty stack (count 0) removes the entity (`setItem(EMPTY)`, then
|
||||
/// `discard`). Players see it on their next tracker update. False when the
|
||||
/// level in place has no such item entity, or on a client. API 42.
|
||||
pub(super) fn native_set_item_entity(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
id: u32,
|
||||
stack: ItemStack,
|
||||
) -> bool {
|
||||
if (self.serving.is_none() && self.options.dedicated.is_none())
|
||||
|| dimension != self.dim.id()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(i) = self.dropped.iter().position(|e| e.id == id) else {
|
||||
return false;
|
||||
};
|
||||
if stack.count == 0 {
|
||||
self.dropped.remove(i);
|
||||
} else {
|
||||
self.dropped[i].stack = stack;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Sets property `key` of the block at `pos` to `value`, as a furnace's
|
||||
/// tick sets its `lit` (`Level.setBlock` with `UPDATE_ALL`: neighbours
|
||||
/// and clients are told); false when the block has no such property or
|
||||
/// value. The block entity stays. API 18.
|
||||
/// and clients are told); true when the block changed, false when the
|
||||
/// block has no such property or value, or the property already has
|
||||
/// `value` (as `Level.setBlock` for an unchanged state). The block
|
||||
/// entity stays. API 18.
|
||||
pub(super) fn native_set_block_property(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
@@ -1414,7 +1526,8 @@ impl Game<'_> {
|
||||
/// `native_set_block_property` with `Level.setBlock`'s `flags`, as a
|
||||
/// brewing stand's tick sets its bottles with `flags::UPDATE_CLIENTS`.
|
||||
/// Players get the write whatever the flags (`flags`); false when this
|
||||
/// window isn't a server. API 33.
|
||||
/// window isn't a server, and in each case `native_set_block_property`
|
||||
/// returns false. API 33.
|
||||
pub(super) fn native_set_block_property_with(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
@@ -1427,12 +1540,9 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let Some(id) = with_property(self.pack.assets.registry, state, key, value) else {
|
||||
let Some(id) = property_change(self.pack.assets.registry, state, key, value) else {
|
||||
return false;
|
||||
};
|
||||
if id == state {
|
||||
return true;
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, id, flags))
|
||||
}
|
||||
@@ -1691,6 +1801,12 @@ impl Game<'_> {
|
||||
let mods = Self::options_path().with_file_name(mod_list::DIR);
|
||||
mods.join(".horizon-mod-data").join(mod_id)
|
||||
}
|
||||
/// The folder mod `mod_id`'s post-build step wrote into when the launcher built the game
|
||||
/// (`native-data/<id>/` in the build's version folder); None when the game's own path is
|
||||
/// unknown. API 41.
|
||||
pub(super) fn native_built_data(&self, mod_id: &str) -> Option<PathBuf> {
|
||||
horizon_client::native_data::built(mod_id)
|
||||
}
|
||||
/// A mod's setting `key` (one file per key in `native_data_dir`).
|
||||
pub(super) fn native_setting(&self, mod_id: &str, key: &str) -> Option<String> {
|
||||
let path = self.native_data_dir(mod_id).join(key);
|
||||
@@ -2439,6 +2555,18 @@ fn with_property(
|
||||
registry.resolve(&s).ok()
|
||||
}
|
||||
|
||||
/// The state a mod's property write changes `state` to; none when the block
|
||||
/// has no such property or value, or already has it: `LevelChunk.setBlockState`
|
||||
/// returns null for the same state, so `Level.setBlock` returns false.
|
||||
fn property_change(
|
||||
registry: &BlockRegistry,
|
||||
state: BlockStateId,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Option<BlockStateId> {
|
||||
with_property(registry, state, key, value).filter(|&id| id != state)
|
||||
}
|
||||
|
||||
/// `config/` beside `options`, made when missing.
|
||||
fn config_dir(options: &Path) -> Result<PathBuf, String> {
|
||||
let dir = options.with_file_name("config");
|
||||
@@ -2451,6 +2579,31 @@ fn finite(v: f32, lo: f32, hi: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(lo, hi) } else { lo }
|
||||
}
|
||||
|
||||
/// An item entity as `native_item_entities` lists it. API 42.
|
||||
/// Read by mods only.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct NativeItemEntity {
|
||||
/// The entity's ID, for `native_set_item_entity`.
|
||||
pub id: u32,
|
||||
pub position: [f64; 3],
|
||||
pub stack: ItemStack,
|
||||
/// Ticks before a player may pick it up (`ItemEntity.pickupDelay`);
|
||||
/// hoppers take it whatever this is.
|
||||
pub pickup_delay: u32,
|
||||
}
|
||||
|
||||
/// `AABB.intersects`: the boxes overlap, touching faces excluded.
|
||||
fn box_meets(b: &horizon_core::geometry::Aabb, min: [f64; 3], max: [f64; 3]) -> bool {
|
||||
let (lo, hi) = (b.min_position(), b.max_position());
|
||||
lo.x < max[0]
|
||||
&& hi.x > min[0]
|
||||
&& lo.y < max[1]
|
||||
&& hi.y > min[1]
|
||||
&& lo.z < max[2]
|
||||
&& hi.z > min[2]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -2476,6 +2629,31 @@ mod tests {
|
||||
);
|
||||
assert_eq!(super::with_property(®istry, age0, "age", "9"), None);
|
||||
assert_eq!(super::with_property(®istry, age0, "lit", "true"), None);
|
||||
// What both property setters write: nothing (false) when the
|
||||
// property already has the value, as `Level.setBlock`.
|
||||
assert_eq!(super::property_change(®istry, age0, "age", "0"), None);
|
||||
assert_eq!(
|
||||
super::property_change(®istry, age0, "age", "1"),
|
||||
Some(age1)
|
||||
);
|
||||
assert_eq!(super::property_change(®istry, age0, "age", "9"), None);
|
||||
assert_eq!(super::property_change(®istry, age0, "lit", "true"), None);
|
||||
}
|
||||
/// Both property setters return false for a write that changes nothing:
|
||||
/// each goes through `property_change`, none returns true on its own.
|
||||
#[test]
|
||||
fn property_setters_refuse_no_change() {
|
||||
let source = include_str!("native_hooks.rs");
|
||||
let code = source.split("#[cfg(test)]").next().unwrap_or_default();
|
||||
for head in [
|
||||
" pub(super) fn native_set_block_property(\n &mut self,\n ticks:",
|
||||
" pub(super) fn native_set_block_property_with(",
|
||||
] {
|
||||
let body = code.split(head).nth(1).expect(head);
|
||||
let body = &body[..body.find("\n }\n").expect(head)];
|
||||
assert!(body.contains("property_change("), "{head}");
|
||||
assert!(!body.contains("return true"), "{head}");
|
||||
}
|
||||
}
|
||||
/// horizon-mixins refuses targets whose body has a `cfg` or a macro:
|
||||
/// the hook points and helpers must stay free of both. The launcher
|
||||
@@ -2527,7 +2705,22 @@ mod tests {
|
||||
|
||||
let catalog: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../../../../native-hooks.json")).unwrap();
|
||||
let hooks = catalog["hooks"].as_array().unwrap();
|
||||
let mut hooks = catalog["hooks"].as_array().unwrap().clone();
|
||||
let fragments = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../tools/native-hook-fragments");
|
||||
for entry in std::fs::read_dir(fragments).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_some_and(|ext| ext == "json") {
|
||||
let fragment: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
hooks.extend(fragment["hooks"].as_array().unwrap().iter().cloned().map(
|
||||
|mut hook| {
|
||||
hook["version"] = 1.into();
|
||||
hook
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
let source = include_str!("native_hooks.rs");
|
||||
let section = source
|
||||
.split("/// The hook points. Each does nothing in Horizon.")
|
||||
@@ -2564,7 +2757,7 @@ mod tests {
|
||||
.collect();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut contracts = BTreeSet::new();
|
||||
for hook in hooks {
|
||||
for hook in &hooks {
|
||||
let name = hook["name"].as_str().unwrap();
|
||||
let version = hook["version"].as_u64().unwrap();
|
||||
assert!(
|
||||
|
||||
@@ -144,7 +144,8 @@ impl Game<'_> {
|
||||
let Some(remote) = self.remote.as_ref() else {
|
||||
return Err("the server carries no mod messages".into());
|
||||
};
|
||||
if !remote.joined {
|
||||
// A server that reconfigures the player has them out of the play state.
|
||||
if !remote.joined || remote.reconfiguring.is_some() {
|
||||
return Err("the player has not joined yet".into());
|
||||
}
|
||||
if !remote.mod_net {
|
||||
|
||||
Loaded 100 of 320 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user