Merge upstream main into fix/shop-window-clock

Keeps the Paddle checkout from main and the clock and unknown-state fix. Both sides added tests at the end of wardrobe/tests.rs and an item to the same import in wardrobe_page/shop.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp7yEJwPTJ2kw29LWdk1
This commit is contained in:
martipopsandClaude Opus 5.5 committed 2026-11-04 09:42:37 -05:00
commit 4682b7838c
285 files changed
+8379 -1751

No files matched your search

+2 -2
View File
@@ -868,7 +868,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526). The templates' block entities now keep their patterns ([#1928](https://github.com/horizon-rust/horizon/issues/1928)).
</details>
@@ -878,7 +878,7 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660). The loot's enchantment functions now reach every table that rolls ([#1911](https://github.com/horizon-rust/horizon/issues/1911), [#2037](https://github.com/horizon-rust/horizon/issues/2037)).
</details>
+237 -39
View File
@@ -179,12 +179,13 @@ impl PadState {
#[derive(Clone, Debug, Default)]
pub struct Edges {
held: u32,
suppressed: u32,
}
impl Edges {
pub fn update(&mut self, state: &PadState, threshold: f32) -> Vec<(Button, bool)> {
let mut now = state.buttons;
for (trigger, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
let was = self.held & button.bit() != 0;
let was = (self.held | self.suppressed) & button.bit() != 0;
let value = state.triggers[trigger];
let on = if was {
value >= (threshold - 0.1).max(0.05)
@@ -197,6 +198,8 @@ impl Edges {
now &= !button.bit();
}
}
self.suppressed &= now;
now &= !self.suppressed;
let changed = now ^ self.held;
self.held = now;
Button::ALL
@@ -217,6 +220,50 @@ impl Edges {
pub fn is_held(&self, button: Button) -> bool {
self.held & button.bit() != 0
}
/// Cancel a context without turning its still-held buttons into new presses.
pub fn cancel(&mut self) -> Vec<(Button, bool)> {
self.suppressed |= self.held;
self.release_all()
}
}
/// Intentional activity may take input ownership; an unchanged hold may not.
#[derive(Default)]
pub struct Activity {
buttons: u32,
sticks: [[f32; 2]; 2],
}
impl Activity {
pub fn observe(&mut self, state: &PadState, settings: &PadSettings) -> bool {
let mut buttons = state.buttons;
for (i, button) in [(0, Button::LeftTrigger), (1, Button::RightTrigger)] {
let threshold = if self.buttons & button.bit() != 0 {
(settings.trigger_threshold - 0.1).max(0.05)
} else {
settings.trigger_threshold
};
if state.triggers[i] >= threshold {
buttons |= button.bit();
}
}
let mut activity = buttons & !self.buttons != 0;
self.buttons = buttons;
for (i, (stick, deadzone)) in [
(state.left, settings.move_deadzone),
(state.right, settings.look_deadzone),
]
.into_iter()
.enumerate()
{
if length(stick) <= deadzone {
self.sticks[i] = [0.0; 2];
} else if length([stick[0] - self.sticks[i][0], stick[1] - self.sticks[i][1]]) > 0.08 {
activity = true;
self.sticks[i] = stick;
}
}
activity
}
}
/// Horizon's pad-only actions (no vanilla key mapping).
@@ -248,6 +295,39 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.smoothCamera", None),
];
/// How a bindable action reaches the game: one of its own handlers, shared
/// with the keyboard where the keyboard has one, or else the vanilla default
/// key or button of its mapping, as a rebound keyboard key would.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Route {
HotbarStep {
next: bool,
},
/// Held, or flipped when the pad's toggle sneak is on.
Sneak,
Screenshot,
SmoothCamera,
Key(winit::keyboard::KeyCode),
Mouse(winit::event::MouseButton),
}
/// The route of a bindable action; `None` for one the pad can't reach.
pub fn route(action: &str) -> Option<Route> {
match action {
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
"key.sneak" => Some(Route::Sneak),
"key.screenshot" => Some(Route::Screenshot),
"key.smoothCamera" => Some(Route::SmoothCamera),
_ => {
let default = crate::keybinds::KeyBinds::default_of(action);
crate::keybinds::mouse_button(default)
.map(Route::Mouse)
.or_else(|| crate::keybinds::key_code(default).map(Route::Key))
}
}
}
/// An action's name in the binds list: vanilla's translation key, or
/// Horizon's own English for the pad-only actions.
pub fn action_caption(action: &str) -> &str {
@@ -313,31 +393,6 @@ impl GlyphStyle {
}
}
/// How a controller drives menus (docs/controllers.md, "Menus and
/// inventories").
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum MenuNav {
/// A console-style highlight moved between widgets and slots.
#[default]
Focus,
/// A virtual mouse pointer.
Pointer,
}
impl MenuNav {
pub fn name(self) -> &'static str {
match self {
MenuNav::Focus => "focus",
MenuNav::Pointer => "pointer",
}
}
pub fn parse(name: &str) -> Self {
match name {
"pointer" => MenuNav::Pointer,
_ => MenuNav::Focus,
}
}
}
/// The on-screen keyboard A opens on a text field in focus mode.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum TextKeyboard {
@@ -399,9 +454,12 @@ pub struct PadSettings {
/// holding it, unless vanilla's toggle crouch is on already.
pub toggle_sneak: bool,
pub glyphs: GlyphStyle,
/// How the pad drives menus: a focus highlight or a virtual pointer.
pub menu_nav: MenuNav,
pub keyboard: TextKeyboard,
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
pub pointer_speed: f32,
pub scroll_speed: f32,
pub repeat_delay: f32,
pub repeat_interval: f32,
/// Action name -> button, `None` for unbound; missing means the default.
bindings: BTreeMap<&'static str, Option<Button>>,
}
@@ -420,8 +478,11 @@ impl Default for PadSettings {
trigger_threshold: 0.4,
toggle_sneak: true,
glyphs: GlyphStyle::Auto,
menu_nav: MenuNav::Focus,
keyboard: TextKeyboard::Auto,
pointer_speed: 1.0,
scroll_speed: 12.0,
repeat_delay: 0.35,
repeat_interval: 0.09,
bindings: BTreeMap::new(),
}
}
@@ -462,6 +523,13 @@ impl PadSettings {
.filter(|v| v.is_finite())
.map_or(d, |v| v.clamp(0.0, 1.0))
};
let bounded = |default: f32, min: f32, max: f32| {
value
.parse::<f32>()
.ok()
.filter(|v| v.is_finite())
.map_or(default, |v| v.clamp(min, max))
};
match key.trim() {
"enabled" => s.enabled = flag(s.enabled),
"vibration" => s.vibration = flag(s.vibration),
@@ -477,8 +545,11 @@ impl PadSettings {
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"menuNavigation" => s.menu_nav = MenuNav::parse(value),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
other => {
if let Some(action) = other.strip_prefix("bind_") {
let button = if value == "none" {
@@ -497,7 +568,7 @@ impl PadSettings {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nmenuNavigation:{}\nkeyboard:{}\n",
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nkeyboard:{}\n",
bool_text(self.enabled),
bool_text(self.vibration),
bool_text(self.adaptive_triggers),
@@ -510,9 +581,12 @@ impl PadSettings {
self.trigger_threshold,
bool_text(self.toggle_sneak),
self.glyphs.name(),
self.menu_nav.name(),
self.keyboard.name(),
);
text.push_str(&format!(
"pointerSpeed:{}\nscrollSpeed:{}\nrepeatDelay:{}\nrepeatInterval:{}\n",
self.pointer_speed, self.scroll_speed, self.repeat_delay, self.repeat_interval,
));
for (action, button) in &self.bindings {
text.push_str(&format!(
"bind_{action}:{}\n",
@@ -1163,6 +1237,131 @@ pub fn bow_power(ticks: u32) -> f32 {
mod tests {
use super::*;
#[test]
fn every_bindable_action_reaches_the_game() {
for (action, _) in ACTIONS {
assert!(route(action).is_some(), "{action} has nowhere to go");
}
}
#[test]
fn navigation_preferences_migrate_and_stay_with_each_player() {
let dir = std::env::temp_dir().join(format!(
"horizon-pad-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let p2 = dir.join("P2");
fs::create_dir_all(&p2).unwrap();
let fresh = PadSettings::load(&dir);
assert_eq!(fresh, PadSettings::default());
// An older file, with the removed menuNavigation key: bindings kept.
let legacy =
PadSettings::parse("menuNavigation:pointer\nbind_key.jump:east\nlookSensitivity:0.7\n");
assert_eq!(legacy.binding("key.jump"), Some(Button::East));
assert_eq!(legacy.look_sensitivity, 0.7);
fresh.save(&dir);
assert!(!dir.join(FILE).exists(), "defaults write no file");
let mut other = legacy;
other.pointer_speed = 2.5;
other.scroll_speed = 24.0;
other.repeat_delay = 0.6;
other.repeat_interval = 0.12;
other.save(&p2);
assert_eq!(PadSettings::load(&p2), other);
assert_eq!(PadSettings::load(&dir), fresh, "P2 did not write P1");
let invalid = PadSettings::parse(
"pointerSpeed:NaN\nscrollSpeed:inf\nrepeatDelay:-10\nrepeatInterval:20\n",
);
assert_eq!((invalid.pointer_speed, invalid.scroll_speed), (1.0, 12.0));
assert_eq!((invalid.repeat_delay, invalid.repeat_interval), (0.1, 0.3));
let bounded = PadSettings::parse(
"pointerSpeed:10\nscrollSpeed:-1\nrepeatDelay:NaN\nrepeatInterval:inf\n",
);
assert_eq!((bounded.pointer_speed, bounded.scroll_speed), (3.0, 1.0));
assert_eq!(
(bounded.repeat_delay, bounded.repeat_interval),
(0.35, 0.09)
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn ownership_requires_new_activity_and_ignores_drift() {
let settings = PadSettings::default();
let mut p1 = Activity::default();
let mut p2 = Activity::default();
let mut state = PadState {
left: [0.04, 0.03],
..PadState::default()
};
assert!(!p1.observe(&state, &settings));
state.set(Button::South, true);
assert!(p1.observe(&state, &settings));
assert!(
!p1.observe(&state, &settings),
"held pad cannot reclaim mouse ownership"
);
assert!(
p2.observe(&state, &settings),
"another player's tracker is independent"
);
state.set(Button::South, false);
assert!(!p1.observe(&state, &settings));
state.left = [0.5, 0.0];
assert!(p1.observe(&state, &settings));
state.left = [0.52, 0.0];
assert!(!p1.observe(&state, &settings));
for x in [0.54, 0.56] {
state.left = [x, 0.0];
assert!(!p1.observe(&state, &settings));
}
state.left = [0.6, 0.0];
assert!(
p1.observe(&state, &settings),
"slow intentional movement accumulates"
);
state.left = [0.0; 2];
state.triggers[0] = 0.5;
assert!(p1.observe(&state, &settings));
state.triggers[0] = 0.35;
assert!(!p1.observe(&state, &settings));
state.triggers[0] = 0.45;
assert!(
!p1.observe(&state, &settings),
"trigger threshold noise is not new activity"
);
}
#[test]
fn cancelled_holds_stay_released_until_neutral_then_press_again() {
let mut edges = Edges::default();
let mut state = PadState::default();
state.set(Button::South, true);
state.triggers[1] = 0.8;
assert_eq!(
edges.update(&state, 0.4),
vec![(Button::South, true), (Button::RightTrigger, true)]
);
assert_eq!(
edges.cancel(),
vec![(Button::South, false), (Button::RightTrigger, false)]
);
assert!(edges.update(&state, 0.4).is_empty());
state.triggers[1] = 0.35;
assert!(edges.update(&state, 0.4).is_empty());
assert!(edges.update(&PadState::default(), 0.4).is_empty());
assert_eq!(edges.update(&state, 0.4), vec![(Button::South, true)]);
state.triggers[1] = 0.7;
assert_eq!(
edges.update(&state, 0.4),
vec![(Button::RightTrigger, true)]
);
}
#[test]
fn defaults_follow_bedrock() {
let s = PadSettings::default();
@@ -1414,14 +1613,13 @@ mod tests {
assert_eq!(stick_direction([0.9, 0.2], 0.5), Some(Button::DPadRight));
assert_eq!(stick_direction([-0.2, -0.8], 0.5), Some(Button::DPadDown));
assert_eq!(stick_direction([0.3, 0.3], 0.5), None);
let mut s = PadSettings::default();
assert_eq!(s.menu_nav, MenuNav::Focus);
s.menu_nav = MenuNav::Pointer;
s.keyboard = TextKeyboard::Horizon;
let back = PadSettings::parse(&s.serialize());
let s = PadSettings {
keyboard: TextKeyboard::Horizon,
..PadSettings::default()
};
assert_eq!(
(back.menu_nav, back.keyboard),
(MenuNav::Pointer, TextKeyboard::Horizon)
PadSettings::parse(&s.serialize()).keyboard,
TextKeyboard::Horizon
);
assert!(steam_keyboard(TextKeyboard::Steam) && !steam_keyboard(TextKeyboard::Horizon));
}
+155
View File
@@ -0,0 +1,155 @@
//! The keys a player's mappings hold, as their vanilla default keys, and which
//! devices hold each one. A key stays held while any device holds it, so one
//! device letting go (splitscreen moving the keyboard to another player, say)
//! can't release what another still presses.
use std::collections::HashMap;
use winit::keyboard::KeyCode;
/// Where a press came from.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
/// The keyboard and mouse, and the scripting socket acting as them.
Keyboard,
Pad,
}
impl Source {
fn bit(self) -> u8 {
match self {
Source::Keyboard => 1,
Source::Pad => 2,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HeldKeys {
held: HashMap<KeyCode, u8>,
}
impl HeldKeys {
pub fn contains(&self, key: &KeyCode) -> bool {
self.held.contains_key(key)
}
/// `source` holds `key`. True when nothing held it before.
pub fn press(&mut self, key: KeyCode, source: Source) -> bool {
let sources = self.held.entry(key).or_insert(0);
let new = *sources == 0;
*sources |= source.bit();
new
}
/// `source` lets go of `key`. True when nothing holds it any more.
pub fn release(&mut self, key: &KeyCode, source: Source) -> bool {
let Some(sources) = self.held.get_mut(key) else {
return true;
};
*sources &= !source.bit();
if *sources == 0 {
self.held.remove(key);
true
} else {
false
}
}
pub fn held_by(&self, key: &KeyCode, source: Source) -> bool {
self.held
.get(key)
.is_some_and(|sources| sources & source.bit() != 0)
}
/// `source` lets go of everything: the keys nothing holds any more.
pub fn release_source(&mut self, source: Source) -> Vec<KeyCode> {
let keys: Vec<KeyCode> = self
.held
.iter()
.filter(|(_, sources)| *sources & source.bit() != 0)
.map(|(key, _)| *key)
.collect();
keys.into_iter()
.filter(|key| self.release(key, source))
.collect()
}
/// Held, by the keyboard unless a device already holds it: for callers
/// that don't say where the press came from.
pub fn insert(&mut self, key: KeyCode) -> bool {
if self.contains(&key) {
return false;
}
self.press(key, Source::Keyboard)
}
/// Let go of `key` whoever holds it.
pub fn remove(&mut self, key: &KeyCode) -> bool {
self.held.remove(key).is_some()
}
pub fn clear(&mut self) {
self.held.clear();
}
pub fn is_empty(&self) -> bool {
self.held.is_empty()
}
pub fn len(&self) -> usize {
self.held.len()
}
pub fn iter(&self) -> impl Iterator<Item = &KeyCode> {
self.held.keys()
}
}
impl IntoIterator for HeldKeys {
type Item = KeyCode;
type IntoIter = std::collections::hash_map::IntoKeys<KeyCode, u8>;
fn into_iter(self) -> Self::IntoIter {
self.held.into_keys()
}
}
impl<'a> IntoIterator for &'a HeldKeys {
type Item = &'a KeyCode;
type IntoIter = std::collections::hash_map::Keys<'a, KeyCode, u8>;
fn into_iter(self) -> Self::IntoIter {
self.held.keys()
}
}
impl FromIterator<KeyCode> for HeldKeys {
fn from_iter<I: IntoIterator<Item = KeyCode>>(keys: I) -> Self {
let mut held = HeldKeys::default();
for key in keys {
held.insert(key);
}
held
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_key_stays_held_while_any_device_holds_it() {
let mut keys = HeldKeys::default();
assert!(keys.press(KeyCode::Space, Source::Pad));
assert!(!keys.press(KeyCode::Space, Source::Keyboard));
assert!(keys.press(KeyCode::KeyW, Source::Keyboard));
assert_eq!(keys.release_source(Source::Keyboard), vec![KeyCode::KeyW]);
assert!(keys.contains(&KeyCode::Space) && !keys.contains(&KeyCode::KeyW));
assert!(keys.held_by(&KeyCode::Space, Source::Pad));
assert!(!keys.held_by(&KeyCode::Space, Source::Keyboard));
assert!(keys.release(&KeyCode::Space, Source::Pad));
assert!(keys.is_empty());
}
#[test]
fn untagged_inserts_and_removes_act_as_before() {
let mut keys = HeldKeys::default();
keys.press(KeyCode::ShiftLeft, Source::Pad);
assert!(!keys.insert(KeyCode::ShiftLeft), "already held");
assert!(!keys.held_by(&KeyCode::ShiftLeft, Source::Keyboard));
assert!(
keys.remove(&KeyCode::ShiftLeft),
"remove lets go for everyone"
);
assert!(!keys.contains(&KeyCode::ShiftLeft));
assert!(keys.release(&KeyCode::KeyA, Source::Keyboard), "never held");
}
}
+2
View File
@@ -5,6 +5,7 @@
#![forbid(unsafe_code)]
pub mod clipboard;
pub mod gamepad;
pub mod held;
pub mod input;
pub mod interaction_input;
pub mod key_names;
@@ -12,4 +13,5 @@ pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod snap;
pub mod window;
+152
View File
@@ -0,0 +1,152 @@
//! Where the controller's menu cursor settles once the stick is let go, and
//! how it gets there. Every rule's number lives in [`Tuning`], so the feel can
//! be adjusted in one place.
/// The snapping engine's knobs, in GUI pixels and seconds.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tuning {
/// A let-go cursor settles on a target this close to it.
pub radius: f32,
/// How long the glide onto a target takes.
pub glide_seconds: f32,
/// Movement shorter than this is a wobble: the cursor stays where it was.
pub min_travel: f32,
/// A flick that ends still nearest its starting target commits to the
/// neighbour it was heading for once it covered this fraction of the way.
pub commit: f32,
/// How far off the flick's direction a neighbour may lie (cosine of the
/// angle: 0.7 is about 45 degrees).
pub cone: f32,
/// The pointer's speed over a slot or control, as a fraction of its
/// speed elsewhere (1 leaves it unchanged).
pub over_target_speed: f32,
}
pub const TUNING: Tuning = Tuning {
radius: 16.0,
glide_seconds: 0.025,
min_travel: 1.5,
commit: 0.15,
cone: 0.7,
over_target_speed: 1.0,
};
type Point = (f32, f32);
fn distance(a: Point, b: Point) -> f32 {
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
}
/// The target nearest `at`, if one lies within `radius`.
pub fn nearest(at: Point, targets: &[Point], radius: f32) -> Option<usize> {
targets
.iter()
.enumerate()
.map(|(i, t)| (i, distance(at, *t)))
.filter(|(_, d)| *d <= radius)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
}
/// Where a stick movement from `start` to `end` settles among `targets`
/// (their centres), or `None` to leave the cursor where it is.
pub fn settle(start: Point, end: Point, targets: &[Point], tuning: &Tuning) -> Option<usize> {
let near = nearest(end, targets, tuning.radius);
let travel = (end.0 - start.0, end.1 - start.1);
let length = distance(start, end);
if length < tuning.min_travel {
return near;
}
let origin = nearest(start, targets, tuning.radius);
if near != origin {
return near;
}
// Still nearest where it started: a quick flick toward a neighbour.
let Some(o) = origin else {
return near;
};
let from = targets[o];
let heading = (travel.0 / length, travel.1 / length);
let next = targets
.iter()
.enumerate()
.filter(|(i, _)| *i != o)
.filter_map(|(i, t)| {
let to = (t.0 - from.0, t.1 - from.1);
let gap = distance(from, *t);
let aligned = (to.0 * heading.0 + to.1 * heading.1) / gap;
(gap > 0.0 && aligned >= tuning.cone).then_some((i, gap, to))
})
.min_by(|a, b| a.1.total_cmp(&b.1));
match next {
Some((i, gap, to)) => {
let progress = ((end.0 - from.0) * to.0 + (end.1 - from.1) * to.1) / (gap * gap);
if progress >= tuning.commit {
Some(i)
} else {
near
}
}
None => near,
}
}
/// The cursor `seconds` into a glide from `from` to `to`: eased out, so it
/// slows as it lands, and exactly `to` once the glide is over.
pub fn glide(from: Point, to: Point, seconds: f32, tuning: &Tuning) -> Point {
let t = (seconds / tuning.glide_seconds).clamp(0.0, 1.0);
let eased = 1.0 - (1.0 - t).powi(3);
(
from.0 + (to.0 - from.0) * eased,
from.1 + (to.1 - from.1) * eased,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// A row of inventory slots, 18 pixels apart, and the slot below the first.
const SLOTS: [Point; 4] = [(8.0, 8.0), (26.0, 8.0), (44.0, 8.0), (8.0, 26.0)];
#[test]
fn a_short_flick_moves_to_the_neighbour_it_heads_for() {
// 4 pixels right of slot 0: still nearest slot 0, but heading for slot 1.
assert_eq!(settle((8.0, 8.0), (12.0, 8.0), &SLOTS, &TUNING), Some(1));
assert_eq!(settle((8.0, 8.0), (8.0, 12.0), &SLOTS, &TUNING), Some(3));
assert_eq!(settle((26.0, 8.0), (22.0, 8.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn a_wobble_stays_put() {
assert_eq!(settle((8.0, 8.0), (8.5, 8.4), &SLOTS, &TUNING), Some(0));
// Barely started toward the neighbour: not committed.
assert_eq!(settle((8.0, 8.0), (9.6, 8.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn a_long_move_settles_on_the_nearest_target() {
assert_eq!(settle((8.0, 8.0), (42.0, 9.0), &SLOTS, &TUNING), Some(2));
// Far from everything: left where it is.
assert_eq!(settle((8.0, 8.0), (200.0, 200.0), &SLOTS, &TUNING), None);
}
#[test]
fn a_flick_off_any_row_does_not_jump_sideways() {
// Up from slot 0: nothing lies that way, so it stays on slot 0.
assert_eq!(settle((8.0, 8.0), (8.0, 4.0), &SLOTS, &TUNING), Some(0));
}
#[test]
fn the_glide_eases_onto_its_target_and_stops_there() {
let (from, to) = ((0.0, 0.0), (20.0, -10.0));
assert_eq!(glide(from, to, 0.0, &TUNING), from);
let half = glide(from, to, TUNING.glide_seconds / 2.0, &TUNING);
assert!(
half.0 > 10.0 && half.0 < 20.0,
"eased out: past halfway at half time"
);
assert_eq!(glide(from, to, TUNING.glide_seconds, &TUNING), to);
assert_eq!(glide(from, to, 1.0, &TUNING), to);
}
}
@@ -11,6 +11,7 @@ use crate::{
block_entity_anim::{self, EnchantingBook},
breaking::entity_geometry,
fast_hash::FxHashMap,
font::Glyph,
gfx::mark_oit,
hud::Rect,
mesher::{CULL_BACK, Vertex},
@@ -660,7 +661,7 @@ fn sign_text_calls(
} else {
(dark, 0, vertex_light(packed))
};
let advance = |c: char| (scene.glyph)(c).1;
let advance = |c: char| (scene.glyph)(c).advance;
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
text.lines
.iter()
@@ -699,10 +700,19 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
let (cell, advance) = (scene.glyph)(ch);
if let Some(r) = cell {
let corners = [(x, y0 + 8.0), (x + 8.0, y0 + 8.0), (x + 8.0, y0), (x, y0)];
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
let g = (scene.glyph)(ch);
if let Some(cell) = g.rect {
// BakedSheetGlyph: the glyph's own box from the pen, not a
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
let (x0, x1) = (x + g.left, x + g.right);
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
let uvs = [
[cell.u0, cell.v1],
[cell.u1, cell.v1],
[cell.u1, cell.v0],
[cell.u0, cell.v0],
];
let c =
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
@@ -721,7 +731,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
});
}
}
x += advance;
x += g.advance;
}
};
if call.outline != 0 {
@@ -818,8 +828,8 @@ pub struct Scene<'a> {
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
pub glyph: &'a dyn Fn(char) -> Glyph,
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
@@ -3342,7 +3352,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// Compare the cached world path with the original model renderer,
@@ -3610,7 +3620,13 @@ mod tests {
w: 8,
h: 8,
};
(Some(rect), 6.0)
Glyph {
rect: Some(rect),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
}
};
let scene = Scene {
camera: DVec3::ZERO,
@@ -3660,6 +3676,78 @@ mod tests {
assert!(out.iter().all(|v| v.pos[2] == 0.0));
}
/// BakedSheetGlyph draws a glyph over its own box from the pen
/// (x + left .. x + right, y + up .. y + down), not a fixed 8x8 cell. With
/// Force Unicode Font a Latin letter is an 8x16 Unifont glyph at half
/// scale: 4 units wide and 8 tall, advancing 5 (#8699).
#[test]
fn sign_text_draws_each_glyph_over_its_own_box() {
let world = World::new(BlockStateId(0), 1);
let sprites = HashMap::new();
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.125,
w: 8,
h: 16,
}),
left: 0.0,
top: 0.0,
right: 4.0,
bottom: 8.0,
advance: 5.0,
offset: 0.5,
};
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
translucent_sprites: &HashSet::new(),
atlas_revision: 1,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
};
let call = TextCall {
matrix: Mat4::IDENTITY,
x: 10.0,
y: 2.0,
text: "ab".into(),
color: 0xFF00_0000,
outline: 0,
light: 240 | 240 << 8,
};
let mut out = Vec::new();
draw_text(&scene, &call, &mut out);
let boxes: Vec<_> = out
.chunks(4)
.map(|q| {
let xs = q.iter().map(|v| v.pos[0]);
let ys = q.iter().map(|v| v.pos[1]);
[
xs.clone().fold(f32::MAX, f32::min),
xs.fold(f32::MIN, f32::max),
ys.clone().fold(f32::MAX, f32::min),
ys.fold(f32::MIN, f32::max),
]
})
.collect();
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
}
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
@@ -3714,7 +3802,13 @@ mod tests {
w: 8,
h: 8,
};
((c != ' ').then_some(rect), wide.get())
Glyph {
rect: (c != ' ').then_some(rect),
right: 8.0,
bottom: 8.0,
advance: wide.get(),
..Glyph::default()
}
};
let (first_sign, changed_sign, glowing_sign) = (
|_| Some(&first_text),
@@ -3879,7 +3973,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let mut verts = Vec::new();
@@ -3964,7 +4058,7 @@ mod tests {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
@@ -4,6 +4,7 @@ use crate::{
ModelPart, SIGN_TEXT_KEEP, Scene, StaticSectionRevision, classify,
},
chunk::Chunk,
font::Glyph,
hud::Rect,
mesher::Vertex,
signs::{SignData, SignText},
@@ -355,7 +356,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
// Reverse local-position order to check that cache indexing does not
@@ -443,7 +444,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -497,7 +498,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([100, 70, -32], 0)];
@@ -651,18 +652,19 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
};
let sign_data = &data;
let sign = move |_: [i32; 3]| Some(sign_data);
let glyph = |_: char| {
(
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
6.0,
)
let glyph = |_: char| Glyph {
rect: Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0625,
v1: 0.0625,
w: 8,
h: 8,
}),
right: 8.0,
bottom: 8.0,
advance: 6.0,
..Glyph::default()
};
let scene = Scene {
camera: DVec3::new(2.25, 64.5, -3.75),
@@ -735,7 +737,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -836,7 +838,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -890,7 +892,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -951,7 +953,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1046,7 +1048,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pos = [0, 64, 0];
@@ -1134,7 +1136,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
@@ -1209,7 +1211,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1287,7 +1289,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
sign: &|_| None,
banner: &banner_patterns,
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let blocks = [([0, 64, 0], 0)];
@@ -1353,7 +1355,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let pos = [0, 64, 0];
@@ -1400,7 +1402,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -1505,7 +1507,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyph: &|_| Glyph::default(),
glyphs: 0,
};
let options = CacheOptions {
@@ -58,6 +58,24 @@ impl DataPack {
}
out
}
/// The files of `data/<namespace>/<folder>/` in every namespace, as
/// `list` names them; a folder pack walks only those folders.
pub fn list_folder(&self, folder: &str) -> Vec<String> {
let files = self.list_kinds(&[folder]);
let Source::Zip(_) = &self.source else {
return files;
};
files
.into_iter()
.filter(|f| {
f.strip_prefix("data/")
.and_then(|r| r.split_once('/'))
.is_some_and(|(_, r)| {
r.strip_prefix(folder).is_some_and(|r| r.starts_with('/'))
})
})
.collect()
}
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
match &self.source {
Source::Zip(z) => z.read(path).ok(),
+1 -1
View File
@@ -24,7 +24,7 @@ pub const DEFAULT: &str = "minecraft:default";
/// A glyph ready to draw: atlas UVs and its box relative to the pen
/// position (GlyphBitmap left/top/right/bottom), in GUI pixels.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, Default)]
pub struct Glyph {
pub rect: Option<Rect>,
pub left: f32,
+626 -62
View File
@@ -324,6 +324,26 @@ fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
/// whose column left), with the chunk fade-in `fade_ms`.
fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
uploaded.map_or(1.0, |&t| {
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
chunk_visibility(elapsed, fade_ms)
})
}
/// What a section's fade depends on besides the section itself
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
/// first uploads (`Renderer::section_uploaded`) and those held while far
/// terrain comes in (`Renderer::lod_retiring`).
#[derive(Clone, Copy)]
struct FadeInputs<'a> {
now: Instant,
fade_ms: u64,
uploaded: &'a FxHashMap<SectionKey, Instant>,
retiring: &'a crate::fast_hash::FxHashSet<SectionKey>,
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
/// A new value for `Renderer::block_entity_revision`.
@@ -419,6 +439,10 @@ struct SectionGpu {
/// Its record's id in GPU culling's table (`gpu_culling::Records`), while
/// that is on and it has solid quads.
gpu_id: Option<u32>,
/// Its record is `FADING` (GPU culling on): the CPU draws its solid quads
/// while its instance carries a fade (`drawn_faded`), and GPU culling's
/// pass leaves it out.
gpu_fading: bool,
/// When it began to fade in over the far terrain it replaces, which
/// stays whole behind it until then: a section new where far tiles are
/// drawn. Meshed again, it stays as it was.
@@ -486,7 +510,7 @@ impl SectionGpu {
}
}
/// GPU culling's record of it at `key`, its quads within `bounds`
/// (`SectionMesh::bounds`).
/// (`SectionMesh::bounds`), `FADING` while it fades (`gpu_fading`).
fn record(&self, key: SectionKey, bounds: Option<[Vec3; 2]>) -> gpu_culling::Record {
let boxed = gpu_culling::rounded_box(bounds);
gpu_culling::Record::new(
@@ -496,13 +520,44 @@ impl SectionGpu {
self.solid,
boxed,
)
.fading(self.gpu_fading)
}
/// Whether GPU frames' walk lists it within the render distance
/// (`Resident::walk`): its mark, kept whether GPU culling is on or
/// not. Its translucent quads are the CPU's, or GPU culling can have no
/// record of it.
/// (`Resident::walk`): its mark. Its translucent quads are the CPU's, or
/// GPU culling can have no record of it, which holds whether GPU culling is
/// on or not; or, while it is, its solid quads are the CPU's while it fades
/// (`gpu_fading`).
fn listed(&self, key: SectionKey, sections: usize) -> bool {
self.translucent > 0 || !self.gpu_recorded(key, sections)
self.translucent > 0 || !self.gpu_recorded(key, sections) || self.gpu_fading
}
/// Whether GPU culling draws its solid quads in a GPU frame, `near`: its
/// column within the render distance.
fn gpu_draws_solid(&self, near: bool) -> bool {
near && self.gpu_id.is_some() && !self.gpu_fading
}
/// Whether frames draw it faded at `f.now`: its instance, as the instance
/// loop makes it (`section_instance` with its `fade` and its vanilla
/// visibility), is not a whole section's. As time goes on a fade only ends:
/// once false, this stays false until the section is meshed again, leaves,
/// is held (`lod_retiring`), or the chunk fade-in time changes.
fn drawn_faded(&self, key: SectionKey, f: FadeInputs<'_>) -> bool {
let fade = self.fade(f.now, f.retiring.contains(&key));
let visibility = section_visibility(f.uploaded.get(&key), f.fade_ms, f.now);
section_instance([0.0; 3], fade, visibility)[3].to_bits()
!= section_instance([0.0; 3], 0, 1.0)[3].to_bits()
}
/// When its fade is over at the latest (`drawn_faded`): the later of the
/// ends of its fade-in over far terrain (`fading` and `lod::FADE`, a few
/// milliseconds after `fade` reaches 0) and of vanilla's (its first upload
/// and `f.fade_ms`). None while it leaves or is held, which ends only when
/// it goes or is meshed again, and without either fade-in.
fn fade_end(&self, key: SectionKey, f: FadeInputs<'_>) -> Option<Instant> {
if self.leaving.is_some() || f.retiring.contains(&key) {
return None;
}
let vanilla = f.uploaded.get(&key).filter(|_| f.fade_ms > 0);
let vanilla = vanilla.map(|&t| t + std::time::Duration::from_millis(f.fade_ms));
vanilla.max(self.fading.map(|t| t + lod::FADE))
}
}
/// Which of a mesh's ranges `move_off_page` moves: what it holds in the section
@@ -620,6 +675,103 @@ fn begin_shrink<'a>(
arena.close(page);
Some(true)
}
/// GPU culling's flag (`SectionGpu::gpu_fading`) for its recorded section `s`
/// at `key`, decided at `f.now`: whether it draws faded (`drawn_faded`), and if
/// time ends that, its end waited for in `fades` (`fade_end`).
fn decide_fade(
s: &SectionGpu,
key: SectionKey,
fades: &mut gpu_culling::Fades,
f: FadeInputs<'_>,
) -> bool {
let fading = s.drawn_faded(key, f);
if let Some(end) = s.fade_end(key, f).filter(|_| fading) {
fades.push(end, key);
}
fading
}
/// GPU culling: the section at `key` began to fade out over far terrain
/// (`Renderer::leave_section`), or is held while far terrain comes in
/// (`Renderer::lod_retiring`). It draws faded until it goes or is meshed again,
/// so its record is flagged and the CPU draws it, with no end to wait for.
fn hold_fading(
sections: &mut resident::Resident<SectionGpu>,
records: &mut gpu_culling::Records,
key: SectionKey,
) {
let Some(s) = sections.find_mut(key).filter(|s| !s.gpu_fading) else {
return;
};
let Some(id) = s.gpu_id else {
return;
};
s.gpu_fading = true;
records.set_fading(id, true);
sections.set_marked(key, true);
}
/// GPU culling switched off (`Renderer::set_gpu_culling`): no section keeps a
/// record or a flag, and those it flagged get back the walk marks they have
/// while it is off (`SectionGpu::listed`). `rows`: the level's sections.
fn forget_records(sections: &mut resident::Resident<SectionGpu>, rows: usize) {
let mut marks = Vec::new();
for (key, s, _) in sections.iter_mut() {
s.gpu_id = None;
if std::mem::take(&mut s.gpu_fading) {
marks.push((key, s.listed(key, rows)));
}
}
for (key, marked) in marks {
sections.set_marked(key, marked);
}
}
/// GPU culling's fading sections brought up to `f.now`, before a frame's gate
/// and `prepare`: every recorded section anew when the flags were decided with
/// another chunk fade-in time (GPU culling just switched on, or Video Settings
/// changed it), else those whose fade was due to end, handed back to the GPU if
/// whole by then. A section's record (`FADING`) and its walk mark follow its
/// `gpu_fading`. `rows`: the level's sections.
fn mark_fades(
sections: &mut resident::Resident<SectionGpu>,
records: &mut gpu_culling::Records,
fades: &mut gpu_culling::Fades,
rows: usize,
f: FadeInputs<'_>,
) {
let mut marks = Vec::new();
if fades.fade_ms != Some(f.fade_ms) {
fades.clear();
fades.fade_ms = Some(f.fade_ms);
for (key, s, _) in sections.iter_mut() {
let Some(id) = s.gpu_id else {
continue;
};
let fading = decide_fade(s, key, fades, f);
if fading != s.gpu_fading {
s.gpu_fading = fading;
records.set_fading(id, fading);
marks.push((key, s.listed(key, rows)));
}
}
} else {
while let Some(key) = fades.pop_due(f.now) {
// Gone since, or no longer flagged.
let Some(s) = sections.find_mut(key).filter(|s| s.gpu_fading) else {
continue;
};
// Still fading: meshed again since, with its own end waited for
// (`decide_fade`), or leaving or held, with none.
if s.drawn_faded(key, f) {
continue;
}
s.gpu_fading = false;
records.set_fading(s.gpu_id.expect("flagged sections have records"), false);
marks.push((key, s.listed(key, rows)));
}
}
for (key, marked) in marks {
sections.set_marked(key, marked);
}
}
/// Line segments the hit outline can hold.
const MAX_OUTLINE_EDGES: u64 = 256;
/// Staging buffer size for section meshes: a frame of uploads while
@@ -917,8 +1069,8 @@ pub struct Renderer {
cull_on: bool,
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
sides_on: bool,
/// The near solid sections culled and drawn on the GPU (`gpu_culling`): its pass
/// and records while it is on, where the device can (`gpu_culling_supported`),
/// The near solid sections culled and drawn on the GPU (`gpu_culling`): its pass,
/// records and fades while it is on, where the device can (`gpu_culling_supported`),
/// and whether the last frame drew them so (`gpu_frame`).
gpu_culling: Option<gpu_culling::GpuCulling>,
gpu_culling_supported: bool,
@@ -2327,7 +2479,19 @@ impl Renderer {
view_formats: vec![format],
color_space: wgpu::SurfaceColorSpace::default(),
};
(config, Vec::new(), None)
let hdr_output = hdr::offscreen_output();
// Only where one is named: splitscreen's views draw offscreen too, and their
// window's renderer offers what its display does.
if let Some(output) = hdr_output {
crate::graphics::offer_hdr(true);
if previous.is_none() {
log::info!(
"Horizon renderer: HDR output {} offscreen (HORIZON_HDR_OUTPUT)",
output.name()
);
}
}
(config, Vec::new(), hdr_output)
};
let offscreen = surface
.is_none()
@@ -4767,17 +4931,22 @@ impl Renderer {
pub fn gpu_frame(&self) -> bool {
self.gpu_frame
}
/// Switches GPU culling, for A/B checks. Its pass and its records of the
/// resident sections are made when it is switched on and dropped when it is
/// switched off: nothing is kept for it while it is off but the sections'
/// walk marks (`SectionGpu::listed`) and the cave-culling graph's bits
/// Of the sections GPU culling keeps records of, those it leaves to the CPU
/// while they fade (`SectionGpu::gpu_fading`); `None` while it is off.
pub fn gpu_fading(&self) -> Option<u32> {
self.gpu_culling.as_ref().map(|g| g.records.fading())
}
/// Switches GPU culling, for A/B checks. Its pass, its records of the
/// resident sections and their flags while they fade are made when it is
/// switched on (the next world frame decides the flags, `mark_fades`) and
/// dropped when it is switched off, with the walk marks the flags gave:
/// nothing is kept for it while it is off but the sections' other walk marks
/// (`SectionGpu::listed`) and the cave-culling graph's bits
/// (`occlusion::Reach`), which are the same either way.
pub fn set_gpu_culling(&mut self, on: bool) -> Result<()> {
if !on {
if self.gpu_culling.take().is_some() {
for s in self.sections.values_mut() {
s.gpu_id = None;
}
forget_records(&mut self.sections, self.layout.sections());
}
self.gpu_frame = false;
return Ok(());
@@ -4814,9 +4983,13 @@ impl Renderer {
/// section's walk mark is `SectionGpu::listed`: one unmarked that should be
/// would leave its translucent quads, or all of it, undrawn. And the bits
/// the pass copies of the cave-culling graph are its cells'
/// (`occlusion::Reach`): one off would hide a section the walk shows.
/// (`occlusion::Reach`): one off would hide a section the walk shows. And,
/// at `f.now`, a recorded section draws faded only while it is flagged
/// (`SectionGpu::gpu_fading`, its record's `FADING`), and an end is waited
/// for while it is unless it leaves or is held: one the pass drew whole
/// would pop in, one flagged for good would cost the CPU its draws.
#[cfg(debug_assertions)]
fn check_gpu_records(&self) {
fn check_gpu_records(&self, f: FadeInputs<'_>) {
let Some(gpu) = &self.gpu_culling else {
return;
};
@@ -4825,21 +4998,36 @@ impl Renderer {
"the reach bits are the graph's cells"
);
let (records, sections) = (&gpu.records, self.layout.sections());
let pending: crate::fast_hash::FxHashSet<SectionKey> = gpu.fades.pending().collect();
let mut pages = vec![0; records.page_sections().len()];
let mut live = 0;
let (mut live, mut flagged) = (0, 0);
for (key, s, bounds) in self.sections.iter() {
let recorded = s.gpu_recorded(key, sections);
assert_eq!(s.gpu_id.is_some(), recorded, "{key:?}: a record");
let marked = self.sections.is_marked(key);
assert_eq!(marked, s.listed(key, sections), "{key:?}: its walk mark");
assert!(
!s.gpu_fading || s.gpu_id.is_some(),
"{key:?}: flagged without a record"
);
assert!(
s.gpu_id.is_none() || s.gpu_fading || !s.drawn_faded(key, f),
"{key:?}: GPU culling draws it whole while it fades"
);
assert!(
!s.gpu_fading || s.fade_end(key, f).is_none() || pending.contains(&key),
"{key:?}: flagged, with no end waited for"
);
if let Some(id) = s.gpu_id {
let record = s.record(key, bounds);
assert_eq!(records.at(id), Some(&record), "{key:?}: its record {id}");
pages[s.page as usize] += 1;
live += 1;
flagged += u32::from(s.gpu_fading);
}
}
assert_eq!(records.live(), live, "records of sections gone");
assert_eq!(records.fading(), flagged, "flagged records");
assert_eq!(records.page_sections(), pages, "records per page");
for (page, &n) in pages.iter().enumerate() {
assert!(
@@ -4972,6 +5160,17 @@ impl Renderer {
if let Some(gpu) = &mut self.gpu_culling
&& section.gpu_recorded(key, sections)
{
// A fade starts with its section: flagged from the first frame that
// draws it, once a frame has decided the flags (`mark_fades`).
if let Some(fade_ms) = gpu.fades.fade_ms {
let f = FadeInputs {
now: Instant::now(),
fade_ms,
uploaded: &self.section_uploaded,
retiring: &self.lod_retiring,
};
section.gpu_fading = decide_fade(&section, key, &mut gpu.fades, f);
}
section.gpu_id = Some(gpu.records.insert(section.record(key, mesh.bounds)));
}
let marked = section.listed(key, sections);
@@ -5125,6 +5324,7 @@ impl Renderer {
sorted_indices,
flat_id,
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers,
@@ -5496,6 +5696,8 @@ impl Renderer {
self.graveyard.add(self.arena.clear());
if let Some(gpu) = &mut self.gpu_culling {
gpu.records.clear();
// The chunk fade-in time they were decided with stays.
gpu.fades.clear();
}
if let Some(arena) = &mut self.translucent_arena {
self.graveyard.add(arena.clear());
@@ -5629,6 +5831,9 @@ impl Renderer {
{
section.leaving = Some(now);
self.lod_leaving.push((key, now));
if let Some(gpu) = &mut self.gpu_culling {
hold_fading(&mut self.sections, &mut gpu.records, key);
}
}
}
/// Releases the sections that have faded out (`leave_section`).
@@ -5688,6 +5893,9 @@ impl Renderer {
if retiring {
// Distant tiles still leave its column out: drawn until they don't.
self.lod_retiring.insert(key);
if let Some(gpu) = &mut self.gpu_culling {
hold_fading(&mut self.sections, &mut gpu.records, key);
}
} else if !self.lods.is_empty() && self.sections.contains(key) {
// Far terrain draws the column: it fades out over it.
self.leave_section(key);
@@ -5859,6 +6067,8 @@ impl Renderer {
}
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
/// Offscreen (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot
/// taken, but nothing is presented, and Auto takes its defaults: no display reports.
pub fn set_hdr(
&mut self,
on: bool,
@@ -5868,21 +6078,28 @@ impl Renderer {
ui: f32,
marker: &std::path::Path,
) {
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
let output = self
.hdr_output
.filter(|_| on && (self.surface.is_some() || self.offscreen.is_some()));
if output.is_some() != self.hdr.is_some() {
// If the game dies before HDR output shows a frame, the marker is still there at
// the next start, which then comes up with HDR off.
if output.is_some() {
let _ = std::fs::write(marker, "HDR output was being switched on\n");
self.hdr_marker = Some(marker.to_owned());
if self.surface.is_some() {
let _ = std::fs::write(marker, "HDR output was being switched on\n");
self.hdr_marker = Some(marker.to_owned());
}
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
// Only HDR output asks the system about the display, so with HDR off the
// game never waits on the compositor for it.
self.display = if output.is_some() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
let (watch, now) = if self.surface.is_some() {
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter)
} else {
(DisplayWatch::default(), None)
};
crate::graphics::report(now);
self.display_fresh = true;
watch
@@ -6735,19 +6952,24 @@ impl Renderer {
let size = [self.config.width, self.config.height];
let target_texture = match (&surface_frame, &mut self.hdr) {
_ if let Some((real, _)) = &fg_frames => real.clone(),
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
(_, Some(hdr)) => hdr.frame(&self.device, size),
// A screenshot of a swapchain without COPY_SRC (OpenGL), and a 16-bit frame
// with HDR off, draw into a texture of their own, then onto the swapchain.
(Some(f), None) => self
.readback
.target(
&self.device,
&f.texture,
self.frame_format,
self.screenshot.is_some(),
)
.unwrap_or_else(|| f.texture.clone()),
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
// with HDR off, draw into a texture of their own, then onto the swapchain or
// the offscreen target.
(f, None) => {
let image = match f {
Some(f) => f.texture.clone(),
None => self.offscreen.clone().ok_or("no offscreen target")?,
};
self.readback
.target(
&self.device,
&image,
self.frame_format,
self.screenshot.is_some(),
)
.unwrap_or(image)
}
};
// One command buffer for the frame, after the section copies staged
// since the last one: Metal commits every command buffer wgpu-core
@@ -7053,6 +7275,24 @@ impl Renderer {
.as_ref()
.and_then(|p| p.1.as_ref())
.is_some_and(|p| p.has_terrain());
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
let fade_ms = fade_millis(frame.chunk_fade);
// GPU culling's fading sections, decided at `fades.now`: before the gate, the record
// check and `prepare`, which sends their flags, and no later than the instances' `now`,
// so a section the pass draws whole is whole there too. Title frames draw no terrain
// (a loading screen's fade-in time is 0): they leave them as they are.
let fades = FadeInputs {
now: Instant::now(),
fade_ms,
uploaded: &self.section_uploaded,
retiring: &self.lod_retiring,
};
if !frame.title
&& let Some(gpu) = &mut self.gpu_culling
{
let (sections, rows) = (&mut self.sections, self.layout.sections());
mark_fades(sections, &mut gpu.records, &mut gpu.fades, rows, fades);
}
// The near solid sections culled and drawn on the GPU (`gpu_culling`): never where a
// shader pack's terrain, a shadow pass or the water mirror draws the CPU's lists.
// Decided before the walk, so that a pass whose buffers no longer fit can still
@@ -7066,7 +7306,7 @@ impl Renderer {
#[cfg(debug_assertions)]
if gpu_frame {
if self.gpu_checks.is_multiple_of(64) {
self.check_gpu_records();
self.check_gpu_records(fades);
}
self.gpu_checks = self.gpu_checks.wrapping_add(1);
}
@@ -7112,9 +7352,9 @@ impl Renderer {
let mut visible = std::mem::take(&mut self.visible);
visible.clear();
crate::spans::time("visibility", || {
// GPU culling draws the solid quads of the near sections it has records of: within
// the render distance the walk lists only those with translucent quads or without a
// record (`SectionGpu::listed`).
// GPU culling draws the solid quads of the near whole sections it has records of:
// within the render distance the walk lists only those with translucent quads,
// without a record or fading (`SectionGpu::listed`).
self.sections.walk(
camera,
radius,
@@ -7242,7 +7482,6 @@ impl Renderer {
laps.lap("r_cameras");
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`. Once
// nothing is fading (the usual frame), every section is whole without a lookup.
let fade_ms = fade_millis(frame.chunk_fade);
let fading_in = self.sections_fading_in(fade_ms, now);
let fading_over_lods = self.sections_fading_over_lods(now);
let fade_code = move |section: &SectionGpu, key: &SectionKey| {
@@ -7257,12 +7496,7 @@ impl Renderer {
if !fading_in {
return 1.0;
}
uploaded.get(&key).map_or(1.0, |t| {
chunk_visibility(
now.saturating_duration_since(*t).as_millis() as u64,
fade_ms,
)
})
section_visibility(uploaded.get(&key), fade_ms, now)
};
self.origins.extend(
visible
@@ -7356,16 +7590,18 @@ impl Renderer {
!(split_solid && gpu_frame),
"GPU culling is never offered on Metal"
);
// In a GPU frame the GPU draws the solid quads of the sections within the render
// distance that have records (`gpu_culling`), and the walk listed those among them
// with translucent quads. Here stay those without a record and those past the
// distance (far terrain's cutout, and columns leaving).
// In a GPU frame the GPU draws the solid quads of the whole sections within the
// render distance that have records (`SectionGpu::gpu_draws_solid`), and the walk
// listed those among them with translucent quads. Here stay those fading in or out
// (`gpu_fading`), those without a record and those past the distance (far terrain's
// cutout, and columns leaving).
let from = resident::camera_column(camera);
for (i, v) in visible.iter().enumerate() {
let s = self.sections.get(v.slot);
if gpu_frame
&& s.gpu_id.is_some()
&& crate::streaming::in_view_distance(from, radius, v.key.0, v.key.2)
&& s.gpu_draws_solid(crate::streaming::in_view_distance(
from, radius, v.key.0, v.key.2,
))
{
continue;
}
@@ -8301,10 +8537,10 @@ impl Renderer {
);
}
// A GPU frame's walk leaves out the sections GPU culling draws, and nothing reads back
// which it kept: every record counts, an upper bound (the world icon waits for more
// than a few drawn).
// which it kept: every record counts but the flagged ones, which the walk lists, an
// upper bound (the world icon waits for more than a few drawn).
if gpu_frame && let Some(gpu) = &self.gpu_culling {
self.drawn_sections += gpu.records.live();
self.drawn_sections += gpu.records.live() - gpu.records.fading() as usize;
}
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(self.frame_format),
@@ -9935,18 +10171,30 @@ impl Renderer {
self.section_copy_bytes = 0;
self.section_uploads.finish(&encoder);
}
if let Some(f) = &surface_frame {
self.readback
.present(&self.device, &mut encoder, &f.texture);
if let Some(image) = surface_frame
.as_ref()
.map(|f| &f.texture)
.or(self.offscreen.as_ref())
{
self.readback.present(&self.device, &mut encoder, image);
}
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
hdr.present(
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
&mut encoder,
&f.texture,
!frame.title,
self.own_timing.as_mut(),
);
),
// Offscreen nothing shows HDR: the target gets the frame as SDR shows it, for
// splitscreen's compositor.
(Some(_), None) => {
if let Some(image) = &self.offscreen {
self.readback
.draw_onto(&self.device, &mut encoder, &target_texture, image);
}
}
(None, _) => {}
}
// With HDR on, a full-frame screenshot gets an HDR10 copy beside it.
let hdr_readback = readback
@@ -10927,12 +11175,16 @@ mod tests {
/// shaders with storage buffers enough, where Translucent Batching is (indirect
/// multi-draws that aren't OpenGL's) and vanilla frames are never drawn flat, and never
/// on Intel Gen11/Gen12's Vulkan driver, whose draws read instance-rate origins from the
/// wrong first instance, even with flat draws off there.
/// wrong first instance, even with flat draws off there, nor on DX12 with flat page
/// groups, whose count draws leave the base vertex out of the pulled pipelines'
/// `vertex_index`.
#[test]
fn gpu_culling_is_offered_where_counts_are_drawn_and_vanilla_never_draws_flat() {
let vulkan = adapter(wgpu::DeviceType::DiscreteGpu);
let mut gl = adapter(wgpu::DeviceType::DiscreteGpu);
gl.backend = wgpu::Backend::Gl;
let mut dx12 = adapter(wgpu::DeviceType::DiscreteGpu);
dx12.backend = wgpu::Backend::Dx12;
let mut intel = adapter(wgpu::DeviceType::IntegratedGpu);
(intel.vendor, intel.device) = (0x8086, 0x9a49); // Tiger Lake
let counts = wgpu::Features::MULTI_DRAW_INDIRECT_COUNT;
@@ -10966,6 +11218,8 @@ mod tests {
assert!(!offered(&vulkan, counts, compute, 8, false, None));
assert!(!offered(&gl, counts, compute, 8, true, None));
assert!(!offered(&intel, counts, compute, 8, true, None));
assert!(offered(&dx12, counts, compute, 8, true, None));
assert!(!offered(&dx12, counts, compute, 8, true, Some(usize::MAX)));
}
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
@@ -11533,6 +11787,316 @@ mod tests {
// A section fading in over far terrain keeps its dither code unchanged.
assert_eq!(section_instance([0.0; 3], 7, 0.5)[3], -(7.0 + step));
}
/// A section as `Renderer::upload_mesh` makes one, with `solid` and
/// `translucent` quads, fading in over far terrain since `fading`.
fn section(
solid: u32,
translucent: u32,
fading: Option<std::time::Instant>,
) -> super::SectionGpu {
super::SectionGpu {
page: 0,
start: 0,
translucent_at: (0, u64::from(solid) * 4),
solid,
solid_slots: u64::from(solid) * 4,
packed: false,
sides: crate::mesher::Sides::unsorted(solid),
translucent,
sort: super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
gpu_id: None,
gpu_fading: false,
fading,
leaving: None,
animated_layers: Box::new([]),
}
}
/// A section's fade (`SectionGpu::drawn_faded`) only ends as time goes on,
/// never while it leaves or is held, and by its `fade_end`, at most 10 ms
/// after it has (a fade-in over far terrain is whole 9.4 ms before
/// `lod::FADE`): fading in over far terrain or not, first uploaded or not,
/// both, at every chunk fade-in time, in 1 ms steps over 3 s and at its end.
#[test]
fn a_fade_ends_by_its_deadline_and_never_comes_back() {
use super::FadeInputs;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::time::{Duration, Instant};
let ms = Duration::from_millis;
// The steps start at `zero`; what began `t` ms before it began off the steps.
let zero = Instant::now() + Duration::from_secs(3);
let ago = |t: u64| zero - ms(t) + Duration::from_micros(370);
let key = (3, 5, -2);
// Neither leaving nor held, leaving, held, both.
let states = [(false, false), (true, false), (false, true), (true, true)];
for fading in [None, Some(0), Some(300), Some(590), Some(700)] {
for up in [None, Some(0), Some(1), Some(740), Some(2_500)] {
let uploaded: FxHashMap<_, _> = up.map(|t| (key, ago(t))).into_iter().collect();
for (leaving, held) in states {
let mut s = section(1, 0, fading.map(ago));
s.leaving = leaving.then(|| ago(250));
let retiring: FxHashSet<_> = held.then_some(key).into_iter().collect();
for fade_ms in [0, 1, 50, 300, 750, 2_000] {
let case = (fading, up, leaving, held, fade_ms);
let at = |now: Instant| FadeInputs {
now,
fade_ms,
uploaded: &uploaded,
retiring: &retiring,
};
let faded = |now: Instant| s.drawn_faded(key, at(now));
let end = s.fade_end(key, at(zero));
if leaving || held {
assert!(end.is_none(), "{case:?}: an end");
}
let mut was = true;
for t in 0..=3_000 {
let now = zero + ms(t);
let is = faded(now);
assert!(was || !is, "{case:?}: faded again at {t} ms");
was = is;
if leaving || held {
assert!(is, "{case:?}: whole at {t} ms");
} else if end.is_none_or(|end| now >= end) {
assert!(!is, "{case:?}: faded at {t} ms, past its end");
}
}
if let Some(end) = end {
assert!(!faded(end), "{case:?}: faded at its end");
assert!(faded(end - ms(10)), "{case:?}: whole 10 ms before it");
}
}
}
}
}
}
/// GPU culling's fading sections over 5 s of 16 ms frames, with no device
/// (`mark_fades`): some 2,000 sections come, fading in as vanilla does or
/// over far terrain, are meshed again, leave over far terrain, are held while
/// it comes in and let go, and go, while the chunk fade-in time goes 750 ms,
/// 2 s, Off and 750 ms. After every frame the GPU keeps only whole sections,
/// the walk lists every other one, each record's flag is its section's, and
/// a flagged section that neither leaves nor is held still faded a frame
/// before. Each kind of event flags sections, and thousands go back. Once
/// GPU culling is switched off (`forget_records`), every mark is the one a
/// section has while it is off.
#[test]
fn fading_sections_go_to_the_cpu_and_come_back() {
use super::{
FadeInputs, SectionGpu, SectionKey, decide_fade, forget_records, gpu_culling,
hold_fading, mark_fades,
};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::time::{Duration, Instant};
/// The level's sections: those at y -1 and 14 have no record.
const ROWS: usize = 14;
const FRAME: Duration = Duration::from_millis(16);
/// The renderer's sections and what their fades depend on, and GPU
/// culling's records and ends.
#[derive(Default)]
struct Sim {
sections: super::resident::Resident<SectionGpu>,
records: gpu_culling::Records,
fades: gpu_culling::Fades,
uploaded: FxHashMap<SectionKey, Instant>,
retiring: FxHashSet<SectionKey>,
}
impl Sim {
/// `Renderer::upload` at `at`: a new section, fading in over far
/// terrain if `far`, or one meshed again.
fn upload(&mut self, key: SectionKey, quads: (u32, u32), far: bool, at: Instant) {
self.retiring.remove(&key);
let fading = match self.sections.find(key) {
Some(old) => old.fading,
None => far.then_some(at),
};
if fading.is_none() {
self.uploaded.entry(key).or_insert(at);
}
self.release(key);
let mut s = section(quads.0, quads.1, fading);
if s.gpu_recorded(key, ROWS) {
if let Some(fade_ms) = self.fades.fade_ms {
let f = FadeInputs {
now: at,
fade_ms,
uploaded: &self.uploaded,
retiring: &self.retiring,
};
s.gpu_fading = decide_fade(&s, key, &mut self.fades, f);
}
s.gpu_id = Some(self.records.insert(s.record(key, None)));
}
let marked = s.listed(key, ROWS);
self.sections.insert_marked(key, s, None, marked);
}
/// `Renderer::release_section`.
fn release(&mut self, key: SectionKey) {
if let Some(id) = self.sections.remove(key).and_then(|s| s.gpu_id) {
self.records.remove(id);
}
}
/// `Renderer::leave_section`: its column gone (`remove_columns`), or
/// no longer left out by far terrain while held (`set_lod_cutout`).
fn leave(&mut self, key: SectionKey, at: Instant) {
self.uploaded.remove(&key);
if let Some(s) = self.sections.find_mut(key)
&& s.leaving.is_none()
{
s.leaving = Some(at);
hold_fading(&mut self.sections, &mut self.records, key);
}
}
/// `Renderer::remove_columns`' held section: far terrain comes in under it.
fn hold(&mut self, key: SectionKey) {
self.uploaded.remove(&key);
self.retiring.insert(key);
hold_fading(&mut self.sections, &mut self.records, key);
}
fn flagged(&self, key: SectionKey) -> bool {
self.sections.find(key).is_some_and(|s| s.gpu_fading)
}
}
let mut seed = 0x2545_f491_u32;
let mut next = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
// 16 x 16 columns of sections at y -1 to 14.
let key = |r: u32| {
let [x, y, z] = [0, 4, 8].map(|shift| ((r >> shift) % 16) as i32);
(x, y - 1, z)
};
// Solid quads alone, with translucent ones, or translucent ones alone.
let kinds = [(64, 0), (64, 0), (64, 0), (64, 16), (64, 16), (0, 16)];
let quads = |r: u32| kinds[r as usize % 6];
let start = Instant::now();
let mut sim = Sim::default();
for r in 0..4_096 {
if next().is_multiple_of(2) {
sim.upload(key(r), quads(next()), next().is_multiple_of(2), start);
}
}
// Flags set by an upload, `leave_section`, a held section and a fade-in
// time (GPU culling switched on in the first frame), and dropped.
let (mut set_by, mut handed_back) = ([0; 4], 0);
let mut last = start;
for frame in 0..=312 {
let now = start + FRAME * frame;
for event in 0..12 {
let at = last + (now - last) * (event + 1) / 13;
let r = next();
let k = key(r);
let was = sim.flagged(k);
let kind = match (r >> 20) % 16 {
0..=7 => {
sim.upload(k, quads(r >> 12), (r >> 24).is_multiple_of(2), at);
0
}
8 | 9 => {
sim.leave(k, at);
1
}
10 | 11 => {
sim.hold(k);
2
}
12 => {
// Far terrain no longer leaves a held section's column out.
let held = sim.retiring.iter().next().copied();
if let Some(held) = held {
sim.retiring.remove(&held);
sim.leave(held, at);
}
continue;
}
_ => {
sim.uploaded.remove(&k);
sim.release(k);
continue;
}
};
if !was && sim.flagged(k) {
set_by[kind] += 1;
}
}
// Those faded out go (`release_left`).
let gone = |t: Instant| now.saturating_duration_since(t) >= super::lod::FADE;
let left: Vec<SectionKey> = sim
.sections
.iter()
.filter(|(_, s, _)| s.leaving.is_some_and(gone))
.map(|(k, ..)| k)
.collect();
for k in left {
sim.release(k);
}
let flagged_before: FxHashSet<SectionKey> = sim
.sections
.iter()
.filter(|(_, s, _)| s.gpu_fading)
.map(|(k, ..)| k)
.collect();
let fade_ms = [750, 2_000, 0, 750][frame as usize * 4 / 313];
let f = FadeInputs {
now,
fade_ms,
uploaded: &sim.uploaded,
retiring: &sim.retiring,
};
mark_fades(&mut sim.sections, &mut sim.records, &mut sim.fades, ROWS, f);
let a_frame_before = FadeInputs {
now: now - FRAME,
..f
};
let mut flagged = 0;
for (k, s, _) in sim.sections.iter() {
let marked = sim.sections.is_marked(k);
assert_eq!(marked, s.listed(k, ROWS), "frame {frame}, {k:?}: its mark");
// The GPU draws its solid quads whole, or the walk lists it.
assert!(
s.solid == 0 || s.gpu_draws_solid(true) || marked,
"frame {frame}, {k:?}: drawn by neither"
);
assert!(
!s.gpu_draws_solid(true) || !s.drawn_faded(k, f),
"frame {frame}, {k:?}: drawn whole by the GPU while it fades"
);
if let Some(id) = s.gpu_id {
let record = s.record(k, None);
assert_eq!(sim.records.at(id), Some(&record), "frame {frame}, {k:?}");
}
if s.gpu_fading {
flagged += 1;
set_by[3] += usize::from(!flagged_before.contains(&k));
if s.leaving.is_none() && !sim.retiring.contains(&k) {
assert!(
s.drawn_faded(k, a_frame_before),
"frame {frame}, {k:?}: flagged a frame after its fade"
);
}
} else {
handed_back += usize::from(flagged_before.contains(&k));
}
}
assert_eq!(sim.records.fading(), flagged, "frame {frame}");
last = now;
}
assert!(set_by.iter().all(|&n| n > 20), "flags set {set_by:?}");
assert!(handed_back > 2_000, "{handed_back} handed back");
// Switched off with sections still flagged: none keeps a record or a
// flag, and each is marked as while GPU culling is off.
assert!(sim.records.fading() > 0, "none flagged at the end");
forget_records(&mut sim.sections, ROWS);
for (k, s, _) in sim.sections.iter() {
assert!(s.gpu_id.is_none() && !s.gpu_fading, "{k:?}: kept once off");
let off = s.translucent > 0 || !s.gpu_recorded(k, ROWS);
assert_eq!(sim.sections.is_marked(k), off, "{k:?}: its mark once off");
}
}
#[test]
fn terrain_attributes_follow_the_terrain_vertex() {
use super::TerrainVertex;
@@ -885,6 +885,7 @@ mod tests {
sorted_indices: None,
flat_id: None,
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1081,6 +1082,7 @@ mod tests {
sorted_indices: None,
flat_id: Some(id),
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1307,6 +1309,7 @@ mod tests {
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
gpu_id: None,
gpu_fading: false,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -14,9 +14,11 @@
//! each in one draw (the pipelines cull the faces turned away), with the origin
//! the walk would give it: `resident::origin`'s f32 bits, copied, never
//! computed on the GPU. Within the render distance the CPU's walk then lists
//! only the sections whose translucent quads it draws, or that have no record
//! (`Resident::walk`); past it, it lists every section as before.
//! Unlike the walk, the pass draws sections fading in or out whole.
//! only the sections whose translucent quads it draws, that have no record, or
//! that fade (`Resident::walk`); past it, it lists every section as before. A
//! section whose instance carries a fade, fading in or out, has its record
//! flagged (`FADING`, gfx.rs `mark_fades`) until it is whole: the pass leaves
//! it out, and the CPU draws it with its fade.
//!
//! The graph is the one the walk reads that frame (`occlusion::Reach`). Its
//! search keeps the cells it reached as bits too, and `Occlusion` a generation
@@ -59,6 +61,7 @@ use super::{
upload::Uploads,
};
use glam::{DVec3, DVec4, Vec3};
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
/// One section (`gpu_culling.wgsl`'s `Section`).
#[repr(C)]
@@ -68,10 +71,14 @@ pub(super) struct Record {
key: [i32; 4],
/// The first vertex of its solid quads (a draw's base vertex, an i32: a
/// page holds fewer vertices), its solid quads, its box (`rounded_box`),
/// then a word kept at 0.
/// then its flags (`FADING`).
a: [u32; 4],
}
/// In `Record::a[3]`: the CPU draws the section's solid quads while it fades
/// (gfx.rs `SectionGpu::gpu_fading`), and the pass leaves it out.
const FADING: u32 = 1;
impl Record {
const FREE: Self = Self {
key: [0, 0, 0, -1],
@@ -86,9 +93,19 @@ impl Record {
a: [start as u32, quads, boxed, 0],
}
}
/// The record, `FADING` when `on`.
pub fn fading(mut self, on: bool) -> Self {
if on {
self.a[3] |= FADING;
}
self
}
fn page(&self) -> Option<u32> {
u32::try_from(self.key[3]).ok()
}
fn is_fading(&self) -> bool {
(self.a[3] & FADING) != 0
}
}
/// `rounded_box` for bounds past what a box's codes hold, or not finite: no
@@ -359,6 +376,10 @@ pub(super) fn supported(
// The pass binds 8, the last cave culling's graph.
&& limits.max_storage_buffers_per_shader_stage >= 8
&& !super::flat::is_broken_intel_vulkan(info)
// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
// pulled solid pipelines (those of a device with flat page groups) read the page.
// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
&& !(info.backend == wgpu::Backend::Dx12 && flat_min_sections.is_some())
}
/// A buffer that is recreated larger when it must hold more.
@@ -419,6 +440,8 @@ pub(super) struct Records {
all_dirty: bool,
/// Per arena page, the records on it.
page_sections: Vec<u32>,
/// The records flagged `FADING`.
fading: u32,
}
impl Records {
@@ -435,6 +458,7 @@ impl Records {
};
self.records[id as usize] = record;
self.add_to(record.key[3] as u32);
self.fading += u32::from(record.is_fading());
self.mark(id);
id
}
@@ -455,6 +479,7 @@ impl Records {
continue;
};
self.page_sections[page as usize] -= 1;
self.fading -= u32::from(self.records[id as usize].is_fading());
self.records[id as usize] = Record::FREE;
// shortcut: a teleport frees more than `DIRTY_LIMIT`, so `prepare` writes the
// whole table (0.6-2.6 ms at render distance 64); upgrade to a list of freed ids
@@ -502,6 +527,22 @@ impl Records {
self.add_to(page);
self.mark(id);
}
/// The section with `id` began to draw faded, or stopped (gfx.rs
/// `SectionGpu::gpu_fading`): its record is `FADING` when `on`.
pub fn set_fading(&mut self, id: u32, on: bool) {
let page = self.records.get(id as usize).and_then(Record::page);
debug_assert!(page.is_some(), "no record {id} to flag");
if page.is_none() || self.records[id as usize].is_fading() == on {
return;
}
self.records[id as usize].a[3] ^= FADING;
if on {
self.fading += 1;
} else {
self.fading -= 1;
}
self.mark(id);
}
/// Every section went.
pub fn clear(&mut self) {
self.records.clear();
@@ -510,12 +551,17 @@ impl Records {
self.dirty = Vec::new();
self.all_dirty = false;
self.page_sections.clear();
self.fading = 0;
}
/// The sections with a record.
pub fn live(&self) -> usize {
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
self.records.len() - free as usize
}
/// The sections whose record is `FADING`, which the CPU draws.
pub fn fading(&self) -> u32 {
self.fading
}
/// The record of the section with `id`; `None` for a free id.
#[cfg(any(test, debug_assertions))]
pub fn at(&self, id: u32) -> Option<&Record> {
@@ -549,6 +595,42 @@ impl Records {
}
}
/// The flagged sections by when their fade is over at the latest (gfx.rs
/// `SectionGpu::fade_end`), for gfx.rs `mark_fades` to look at them again
/// then, and the chunk fade-in time their flags were decided with: `None`
/// until a frame has decided them, just after GPU culling was switched on.
/// The ends of sections that went or were handed back are skipped as they come.
#[derive(Default)]
pub(super) struct Fades {
due: BinaryHeap<Reverse<(Instant, SectionKey)>>,
pub fade_ms: Option<u64>,
}
impl Fades {
/// The section at `key` is looked at again at `end`.
pub fn push(&mut self, end: Instant, key: SectionKey) {
self.due.push(Reverse((end, key)));
}
/// The section of the earliest end at or before `now`, taken out.
pub fn pop_due(&mut self, now: Instant) -> Option<SectionKey> {
let &Reverse((end, key)) = self.due.peek()?;
if end > now {
return None;
}
self.due.pop();
Some(key)
}
/// Every end goes; `fade_ms` stays.
pub fn clear(&mut self) {
self.due.clear();
}
/// The sections an end is waited for.
#[cfg(debug_assertions)]
pub fn pending(&self) -> impl Iterator<Item = SectionKey> {
self.due.iter().map(|r| r.0.1)
}
}
/// The pass, its buffers and the records it draws from: made when GPU culling
/// is switched on, dropped when it is switched off.
pub(super) struct GpuCulling {
@@ -574,6 +656,9 @@ pub(super) struct GpuCulling {
/// that the arena's compaction moves records while it borrows the
/// renderer's other fields.
pub records: Records,
/// When the flagged records' fades end, a field of its own as `records`
/// is: gfx.rs `mark_fades` takes both beside the renderer's sections.
pub fades: Fades,
/// This frame's pages with records: (page, first draw of its region, the
/// region's size).
pages: Vec<(u32, u32, u32)>,
@@ -700,6 +785,7 @@ impl GpuCulling {
reach_generation: None,
group: None,
records: Records::default(),
fades: Fades::default(),
pages: Vec::new(),
ids: 0,
n_updates: 0,
@@ -975,8 +1061,8 @@ mod tests {
}
/// Where a section's solid quads are: key, arena page, first vertex, quads,
/// and its box.
type Placed = (SectionKey, u32, u64, u32, u32);
/// its box, and whether it fades (`FADING`).
type Placed = (SectionKey, u32, u64, u32, u32, bool);
/// `gpu_culling.wgsl`'s `near`, on the CPU.
fn near(params: &Params, x: i32, z: i32) -> bool {
@@ -1025,7 +1111,7 @@ mod tests {
/// (and the pages `prepare` gives them).
fn keeps(params: &Params, words: &[u32], record: &Record) -> bool {
let (key, a) = (record.key, record.a);
if key[3] < 0 || a[1] == 0 || !near(params, key[0], key[2]) {
if key[3] < 0 || a[1] == 0 || (a[3] & FADING) != 0 || !near(params, key[0], key[2]) {
return false;
}
let c: [u32; 3] = std::array::from_fn(|i| key[i].wrapping_sub(params.first[i]) as u32);
@@ -1099,10 +1185,13 @@ mod tests {
Some([min, max])
}
/// Ids, page counts and changes against a plain list of what each id holds:
/// the lowest free id comes back, free ids at the end leave, counts follow
/// moves (to pages past the last too), a move keeps the box, a free is a
/// change, and more changes than updates take mark the whole table.
/// Ids, page counts, flags and changes against a plain list of what each id
/// holds: the lowest free id comes back, flagged only if inserted so, free
/// ids at the end leave, counts follow moves (to pages past the last too), a
/// move keeps the box and the flag, a free is a change, a flag set changes
/// its record only when it differs, the flagged ones are counted through
/// inserts, flags, removals and clears, and more changes than updates take
/// mark the whole table.
#[test]
fn records_keep_ids_page_counts_and_moves() {
let mut records = Records::default();
@@ -1140,7 +1229,7 @@ mod tests {
};
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
// What each id holds; `None`: a free id.
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11))];
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11, false))];
for step in 0..4_000 {
let live: Vec<u32> = (0..model.len() as u32)
.filter(|&id| model[id as usize].is_some())
@@ -1149,20 +1238,22 @@ mod tests {
rng.below(4 + step / 500),
u64::from(rng.below(1 << 20)) * 256,
);
match rng.below(5) {
let dirty = records.dirty.len();
match rng.below(6) {
op if op < 2 || live.is_empty() => {
let key = (rng.range(-9, 9), rng.range(0, 23), rng.range(-9, 9));
let (quads, boxed) = (1 + rng.below(500), rng.below(1 << 30));
let fading = rng.below(4) == 0;
let lowest = model
.iter()
.position(Option::is_none)
.unwrap_or(model.len());
let record = Record::new(key, page, start, quads, boxed);
let record = Record::new(key, page, start, quads, boxed).fading(fading);
assert_eq!(records.insert(record) as usize, lowest);
if lowest == model.len() {
model.push(None);
}
model[lowest] = Some((key, page, start, quads, boxed));
model[lowest] = Some((key, page, start, quads, boxed, fading));
}
op => {
let id = live[rng.below(live.len() as u32) as usize];
@@ -1170,6 +1261,12 @@ mod tests {
if op < 4 {
records.relocate(id, page, start);
(held.1, held.2) = (page, start);
} else if op == 4 {
let on = rng.below(2) == 0;
records.set_fading(id, on);
let changed = usize::from(held.5 != on);
assert_eq!(records.dirty.len(), dirty + changed, "step {step}");
held.5 = on;
} else {
records.remove(id);
// Marked: the GPU must have it free before the pass culls.
@@ -1183,11 +1280,13 @@ mod tests {
}
assert_eq!(records.ids() as usize, model.len(), "step {step}");
assert_eq!(records.live(), model.iter().flatten().count());
let flagged = model.iter().flatten().filter(|held| held.5).count();
assert_eq!(records.fading() as usize, flagged, "step {step}");
let mut pages = vec![0; records.page_sections().len()];
for (id, held) in model.iter().enumerate() {
let record = held.map(|(key, page, start, quads, boxed)| {
let record = held.map(|(key, page, start, quads, boxed, fading)| {
pages[page as usize] += 1;
Record::new(key, page, start, quads, boxed)
Record::new(key, page, start, quads, boxed).fading(fading)
});
assert_eq!(
records.at(id as u32),
@@ -1204,16 +1303,17 @@ mod tests {
let (mut each, mut all) = (Records::default(), Records::default());
for n in 0..1_000 {
let (key, page, start) = ((n % 31, n % 24, n / 31), n as u32 % 3, n as u64 * 256);
let record = Record::new(key, page, start, 1, 0);
let record = Record::new(key, page, start, 1, 0).fading(n % 7 == 0);
assert_eq!(each.insert(record), all.insert(record));
}
let gone: Vec<u32> = (0..1_000).filter(|id| id % 3 != 1 || *id >= 900).collect();
let counts = |r: &Records| (r.ids(), r.live(), r.fading());
for ids in [&[1u32, 4, 898][..], &gone[..]] {
for &id in ids {
each.remove(id);
}
all.remove_all(ids.iter().copied());
assert_eq!((all.ids(), all.live()), (each.ids(), each.live()));
assert_eq!(counts(&all), counts(&each));
assert_eq!(all.page_sections(), each.page_sections());
assert_eq!(free_ids(&all), free_ids(&each));
assert_eq!(all.dirty, each.dirty);
@@ -1228,8 +1328,9 @@ mod tests {
assert_eq!((all.ids(), all.live(), all.free.len()), (0, 0, 0));
assert!(all.page_sections().iter().all(|&n| n == 0));
assert!(records.fading() > 0);
records.clear();
assert_eq!((records.ids(), records.live()), (0, 0));
assert_eq!((records.ids(), records.live(), records.fading()), (0, 0, 0));
assert!(records.page_sections().is_empty() && records.dirty.is_empty());
for x in 0..DIRTY_LIMIT as i32 {
records.insert(Record::new((x, 0, 0), 0, 0, 1, 0));
@@ -1360,6 +1461,7 @@ mod tests {
("UNBOUNDED", u64::from(UNBOUNDED)),
("REACH", u64::from(REACH)),
("SKY", u64::from(SKY)),
("FADING", u64::from(FADING)),
] {
assert_eq!(constant(name), value, "{name}");
}
@@ -1784,7 +1886,9 @@ mod tests {
/// reached or not: the pass reads the copy it holds when a frame brings the
/// same graph (once through a bind group made anew), sends a new one after
/// a frame the CPU draws, and makes its copy anew for one too large for its
/// room.
/// room. A fifth of the sections fade, flagged anew before each case
/// (`FADING`, sent as updates or with the whole table): the pass leaves
/// them to the CPU.
#[test]
fn the_gpu_draws_what_the_integer_test_keeps() {
on_each_device(draws_match);
@@ -1792,11 +1896,14 @@ mod tests {
fn draws_match(device: &wgpu::Device, queue: &Queue) {
let mut rng = Rng(0x2545_F491_4F6C_DD1D);
// The flags have their own sequence: the cases are otherwise as before.
let mut flags = Rng(0x9E37_79B9_7F4A_7C15);
let (min_y, height) = (-64, 24);
let mut gpu = GpuCulling::new(device).expect("the pass");
let mut uploads = Uploads::new(device);
let mut held: BTreeMap<u32, Placed> = BTreeMap::new();
let (mut camera, mut compared, mut culled) = (DVec3::ZERO, 0, 0);
// Fading sections the frustum and the graph keep, left to the CPU.
let (mut camera, mut compared, mut culled, mut faded) = (DVec3::ZERO, 0, 0, 0);
let mut words = Vec::new();
// Cave culling's graph while a case has one (its generation, from x and
// z, radius, height and whether its search reached the sky) and its bits.
@@ -1897,7 +2004,8 @@ mod tests {
.records
.insert(Record::new(key, page, start, quads, boxed));
assert!(
held.insert(id, (key, page, start, quads, boxed)).is_none(),
held.insert(id, (key, page, start, quads, boxed, false))
.is_none(),
"id {id} twice"
);
};
@@ -1912,6 +2020,13 @@ mod tests {
place(&mut gpu, &mut held, &mut rng);
}
}
// A fifth of the sections fade: the flags that change go up as
// updates, or with the whole table (cases 4 and 18), and through
// case 7's frame the CPU draws.
for (&id, placed) in held.iter_mut() {
placed.5 = flags.below(5) == 0;
gpu.records.set_fading(id, placed.5);
}
let whole = gpu.records.all_dirty;
let mut encoder = device.create_command_encoder(&Default::default());
let input = FrameInputs {
@@ -1947,10 +2062,12 @@ mod tests {
let mut params = tables(&input, &mut words).expect("the planes fit");
params.pages = gpu.pass_pages;
let mut expected = Vec::new();
for &(key, page, start, quads, boxed) in held.values() {
for &(key, page, start, quads, boxed, fading) in held.values() {
let record = Record::new(key, page, start, quads, boxed);
let kept = keeps(&params, &words, &record);
let kept = keeps(&params, &words, &record.fading(fading));
let reached = reach.is_none_or(|r| r.contains(key));
// In view, but fading: the CPU's.
faded += usize::from(fading && reached && keeps(&params, &words, &record));
if kept && let Some(r) = reach {
graphed += 1;
unreached += usize::from(!reached);
@@ -1965,7 +2082,7 @@ mod tests {
let origin = resident::origin(key, min_y, camera).as_vec3();
let instance = super::super::section_instance(origin.to_array(), 0, 1.0);
expected.push((page, start as u32, quads * 6, instance.map(f32::to_bits)));
} else if in_view_distance((cx, cz), radius, key.0, key.2) {
} else if !fading && in_view_distance((cx, cz), radius, key.0, key.2) {
culled += 1;
}
}
@@ -2012,8 +2129,10 @@ mod tests {
// Not a comparison of empty lists, nor of a test that keeps everything.
// The graphs leave out a real share of what the frustum keeps, and their
// sky band keeps sections above a box in some cases and not in others.
// The flags leave out about a fifth of what they would keep.
assert!(compared > 2_000, "{compared} draws");
assert!(culled > 2_000, "{culled} within the distance left out");
assert!(faded * 10 > compared, "{faded} fading of {compared} drawn");
assert!(
unreached * 10 > graphed && unreached < graphed,
"{unreached} of {graphed} in view unreached"
@@ -9,7 +9,7 @@
// them as the GPU schedules, and where faces of two sections meet at the same
// depth the later draw keeps the pixel). Nothing here computes with floats: the
// origins are the CPU's bits, copied, and the frustum test adds integers the
// CPU computed.
// CPU computed. `cull` leaves sections fading in or out to the CPU (`FADING`).
struct Params {
// Ids to test, records `apply` writes, the arena pages the counts and
@@ -45,7 +45,7 @@ struct Section {
// free id).
key: vec4<i32>,
// The first vertex of its solid quads, its solid quads, its box
// (gpu_culling.rs `rounded_box`), then a word unused here.
// (gpu_culling.rs `rounded_box`), then its flags (`FADING`).
a: vec4<u32>,
}
@@ -91,6 +91,8 @@ const UNBOUNDED: u32 = 0x80000000u;
// reached the box's top layer (the sky band).
const REACH: u32 = 1u;
const SKY: u32 = 2u;
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
const FADING: u32 = 1u;
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
fn block_count(block: u32, page: u32) -> u32 {
@@ -186,7 +188,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
}
runs[id] = 0u;
let s = sections[id];
if s.key.w < 0 || s.a.y == 0u || !near(s.key.x, s.key.z) || !reached(s.key.xyz) {
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|| !reached(s.key.xyz) {
return;
}
let page = u32(s.key.w);
+24 -2
View File
@@ -42,18 +42,32 @@ impl Output {
}
}
/// `HORIZON_HDR_OUTPUT`'s names, in order of preference.
const NAMES: [(Output, &str); 2] = [(Output::Pq, "pq"), (Output::ScRgb, "scrgb")];
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
NAMES
.into_iter()
.filter(|(_, name)| only.is_none_or(|o| o == *name))
.map(|(output, _)| output)
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
}
/// The HDR output an offscreen renderer (`--offscreen`) offers: it has no display to ask, so
/// only the one `HORIZON_HDR_OUTPUT` names, for HDR screenshots without an HDR display.
pub fn offscreen_output() -> Option<Output> {
named(std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn named(name: Option<&str>) -> Option<Output> {
NAMES
.into_iter()
.find(|(_, n)| name == Some(*n))
.map(|(output, _)| output)
}
struct Targets {
size: [u32; 2],
@@ -637,7 +651,7 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
#[cfg(test)]
mod tests {
use super::{Output, pick};
use super::{Output, named, pick};
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
#[test]
@@ -687,6 +701,14 @@ mod tests {
assert_eq!(pick(&scrgb, Some("pq")), None);
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
#[test]
fn offscreen_offers_only_the_named_output() {
assert_eq!(named(Some("pq")), Some(Output::Pq));
assert_eq!(named(Some("scrgb")), Some(Output::ScRgb));
assert_eq!(named(Some("hdr10")), None);
assert_eq!(named(None), None);
}
}
#[cfg(test)]
@@ -76,14 +76,14 @@ pub struct WorldDrawn<'a> {
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
/// frame to frame (API @API:world_drawn_motion@).
/// frame to frame (API 371).
pub motion: &'a [(u32, u64)],
/// The shared atlas entity UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
pub quad_indices: &'a wgpu::Buffer,
/// The block textures the terrain samples, for drawing blocks with the player's own
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
/// textures (`native_block_sprites` says where a block's are). API 380.
pub block_sprites: BlockSpriteArrays<'a>,
}
@@ -154,7 +154,7 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API @API:hand_drawn@.
/// runs, with no hand too. API 364.
#[inline(never)]
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
@@ -67,6 +67,17 @@ impl Readback {
let Some(texture) = self.texture.clone() else {
return;
};
self.draw_onto(device, encoder, &texture, surface);
}
/// Draws `frame` onto the swapchain image (or offscreen target) `surface`.
pub fn draw_onto(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
surface: &wgpu::Texture,
) {
// The frame's passes write the swapchain through its non-sRGB view
// (`view_formats`), so the copy goes there unconverted too.
let format = surface.format().remove_srgb_suffix();
@@ -74,7 +85,7 @@ impl Readback {
format: Some(format),
..Default::default()
});
self.blit(device, encoder, &texture, &target, format);
self.blit(device, encoder, frame, &target, format);
}
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
@@ -92,7 +92,7 @@ impl<T> Resident<T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// Every section's key, value and bounds.
#[cfg(debug_assertions)]
#[cfg(any(test, debug_assertions))]
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
let items = self.items.iter().zip(&self.bounds);
items.map(|((key, value), bounds)| (*key, value, *bounds))
@@ -194,6 +194,12 @@ impl<T> Resident<T> {
column.low = column.low.min(key.1);
column.high = column.high.max(key.1);
}
/// Marks or unmarks the section at `key`, if it is resident.
pub fn set_marked(&mut self, key: SectionKey, marked: bool) {
if let Some(&slot) = self.slots.get(&key) {
self.mark_slot(slot, marked);
}
}
/// Marks or unmarks the section in `slot`, in its column's list too.
fn mark_slot(&mut self, slot: u32, marked: bool) {
let was = std::mem::replace(&mut self.marked[slot as usize], marked);
@@ -657,7 +663,8 @@ mod tests {
(!value.is_multiple_of(3)).then(|| [Vec3::splat((value % 5) as f32), Vec3::splat(16.0)])
};
// A deterministic mix of inserts, replacements and removals, marked at
// random: a replacement can mark or unmark its section.
// random: a replacement can mark or unmark its section, and so can a
// mark set alone (`set_marked`), which leaves a key not resident alone.
let mut seed = 0x2545_f491_u32;
for step in 0..4000u32 {
seed ^= seed << 13;
@@ -694,6 +701,12 @@ mod tests {
assert_eq!(removed, expected);
} else if (seed >> 12).is_multiple_of(3) {
assert_eq!(r.remove(key), model.remove(&key).map(|(value, _)| value));
} else if (seed >> 12).is_multiple_of(5) {
let marked = (seed >> 20).is_multiple_of(2);
r.set_marked(key, marked);
if let Some(held) = model.get_mut(&key) {
held.1 = marked;
}
} else {
let marked = (seed >> 20).is_multiple_of(2);
r.insert_marked(key, step, bounds(step), marked);
@@ -1050,7 +1050,9 @@ impl Item {
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
Item::Horizon(H::TranslucentBatching) => {
g.translucent_batching && crate::graphics::translucent_batching_offered()
}
Item::Horizon(H::HdrSmoothGradients) => {
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
}
@@ -3164,13 +3166,13 @@ mod tests {
assert_eq!(page, Some(Page::Advanced));
let mut s = Settings::default();
s.horizon.preset = Preset::Balanced;
assert_eq!(item.switch(&s), Some(true));
let mut ui = GraphicsUi::default();
// Where the device can't, the saved On reads Off, and neither a click nor Enter
// after it does anything: no sound, no change, no save.
crate::graphics::offer_translucent_batching(false);
assert!(item.blocked(&s).is_some());
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
assert_eq!(item.switch(&s), Some(false));
let r = row(&mut ui, &s, item);
let point = (r[0] + 4.0, r[1] + 4.0);
assert!(press(&mut ui, &mut s, point).is_empty());
@@ -3181,6 +3183,7 @@ mod tests {
// Where it can, a click switches it and keeps the preset.
crate::graphics::offer_translucent_batching(true);
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
assert_eq!(item.switch(&s), Some(true));
assert_eq!(
press(&mut ui, &mut s, point),
[Action::ButtonClick, Action::Changed]
+10 -4
View File
@@ -796,10 +796,10 @@ impl HudAtlas {
let g = self.font.glyph(ch);
g.rect.map(|rect| (rect, g.advance))
}
/// A font glyph's atlas cell (none for blank glyphs such as the space) and advance.
pub fn glyph_cell(&self, ch: char) -> (Option<Rect>, f32) {
let g = self.font.glyph(ch);
(g.rect, g.advance)
/// A font glyph in the default font: its atlas cell (none for blank
/// glyphs such as the space), its box and advance.
pub fn glyph_cell(&self, ch: char) -> crate::font::Glyph {
self.font.glyph(ch)
}
pub fn build(root: &Path) -> Result<Self> {
Self::build_over(root, root)
@@ -1274,6 +1274,12 @@ impl HudAtlas {
edition.3 = pixels;
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
images.push((
crate::pad_cursor::SPRITE.into(),
crate::pad_cursor::SIZE,
crate::pad_cursor::SIZE,
crate::pad_cursor::pixels(),
));
if full {
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
+1
View File
@@ -74,6 +74,7 @@ pub mod overlay;
pub mod pack_browser;
pub mod pack_screen;
pub mod packs;
pub mod pad_cursor;
pub mod par;
pub mod particles;
pub mod poi_sections;
+280 -25
View File
@@ -272,13 +272,16 @@ impl Opt {
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
Opt::PadLightbar => "horizon.options.lightbar",
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
Opt::PadPointerSpeed => "horizon.options.pointer_speed",
Opt::PadScrollSpeed => "horizon.options.scroll_speed",
Opt::PadRepeatDelay => "horizon.options.repeat_delay",
Opt::PadRepeatInterval => "horizon.options.repeat_interval",
Opt::PadInvertX => "horizon.options.invert_x",
Opt::PadInvertY => "horizon.options.invert_y",
Opt::PadToggleSneak => "horizon.options.sneak_button",
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
Opt::PadGlyphs => "horizon.options.button_glyphs",
Opt::PadMenuNav => "horizon.options.menu_navigation",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::ModelPart(i) => [
"options.modelPart.cape",
@@ -352,6 +355,10 @@ impl Opt {
Opt::ChatHeightUnfocused => Kind::Slider(s.chat_height_unfocused),
Opt::ChatLineSpacing => Kind::Slider(s.chat_line_spacing),
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
Opt::PadPointerSpeed => unit(s.controller.pointer_speed, 0.25, 3.0),
Opt::PadScrollSpeed => unit(s.controller.scroll_speed, 1.0, 36.0),
Opt::PadRepeatDelay => unit(s.controller.repeat_delay, 0.1, 1.0),
Opt::PadRepeatInterval => unit(s.controller.repeat_interval, 0.03, 0.3),
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
@@ -610,6 +617,23 @@ impl Opt {
),
),
Opt::PadLookSensitivity => percent(c, 2.0 * s.controller.look_sensitivity),
Opt::PadPointerSpeed => percent(c, s.controller.pointer_speed),
Opt::PadScrollSpeed => percent(c, s.controller.scroll_speed / 12.0),
Opt::PadRepeatDelay | Opt::PadRepeatInterval => generic(
c,
Text::tr(
"horizon.options.milliseconds",
&[&format!(
"{:.0}",
1000.0
* if self == Opt::PadRepeatDelay {
s.controller.repeat_delay
} else {
s.controller.repeat_interval
}
)],
),
),
Opt::PadMoveDeadzone | Opt::PadLookDeadzone => {
let v = if self == Opt::PadMoveDeadzone {
s.controller.move_deadzone
@@ -796,10 +820,6 @@ impl Opt {
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
}),
Opt::PadMenuNav => tr(match s.controller.menu_nav {
gamepad::MenuNav::Focus => "horizon.options.menu_focus",
gamepad::MenuNav::Pointer => "horizon.options.menu_pointer",
}),
Opt::PadKeyboard => tr(match s.controller.keyboard {
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
@@ -871,6 +891,10 @@ impl Opt {
Opt::ChatHeightUnfocused => s.chat_height_unfocused = v,
Opt::ChatLineSpacing => s.chat_line_spacing = v,
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
Opt::PadPointerSpeed => s.controller.pointer_speed = step(25.0, 300.0) / 100.0,
Opt::PadScrollSpeed => s.controller.scroll_speed = step(1.0, 36.0),
Opt::PadRepeatDelay => s.controller.repeat_delay = step(100.0, 1000.0) / 1000.0,
Opt::PadRepeatInterval => s.controller.repeat_interval = step(30.0, 300.0) / 1000.0,
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
@@ -1094,12 +1118,6 @@ impl Opt {
.unwrap_or(0) as u8;
s.controller.glyphs = GlyphStyle::ALL[usize::from(next(i, 4))];
}
Opt::PadMenuNav => {
s.controller.menu_nav = match s.controller.menu_nav {
gamepad::MenuNav::Focus => gamepad::MenuNav::Pointer,
gamepad::MenuNav::Pointer => gamepad::MenuNav::Focus,
}
}
Opt::PadKeyboard => {
let all = gamepad::TextKeyboard::ALL;
let i = all
@@ -1344,6 +1362,14 @@ enum WKind {
value: f32,
name: String,
},
/// A float or int option `.option` of the Horizon pack `s.shader_packs[.pack]`: where its
/// value sits between its minimum and maximum.
PackSlider {
label: Text,
value: f32,
pack: usize,
option: usize,
},
Text {
text: Text,
color: u32,
@@ -1372,6 +1398,8 @@ struct Widget {
enum FocusId {
Search,
Slider(Opt),
/// A Horizon pack's option slider: (pack, option).
PackSlider(usize, usize),
Cycle(Opt),
Act(Act),
Lang(String),
@@ -1381,6 +1409,7 @@ enum FocusId {
fn focus_id(i: usize, wd: &Widget) -> FocusId {
match &wd.kind {
WKind::Slider { opt, .. } => FocusId::Slider(*opt),
WKind::PackSlider { pack, option, .. } => FocusId::PackSlider(*pack, *option),
WKind::Button {
act: Act::Cycle(opt),
..
@@ -1394,6 +1423,9 @@ fn focus_id(i: usize, wd: &Widget) -> FocusId {
fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
match (id, &wd.kind) {
(FocusId::Slider(a), WKind::Slider { opt, .. }) => a == opt,
(FocusId::PackSlider(p, o), WKind::PackSlider { pack, option, .. }) => {
(p, o) == (pack, option)
}
(
FocusId::Cycle(a),
WKind::Button {
@@ -1512,6 +1544,9 @@ pub struct OptionsUi {
/// The shader pack option whose slider is held, and the handle's own position: it follows
/// the pointer while the option takes the nearest of its values.
shader_dragging: Option<(String, f32)>,
/// The same for a Horizon pack's option: (pack, option, the handle's position, whether its
/// value changed since the press). The pack saves its values, and reloads, on release.
pack_dragging: Option<(usize, usize, f32, bool)>,
/// Shift is held during the click (it resets a shader pack option to its default).
pub shift: bool,
/// The pressed slider's own position (`AbstractSliderButton.value`) and
@@ -1602,6 +1637,7 @@ impl Default for OptionsUi {
scroll: 0.0,
dragging: None,
shader_dragging: None,
pack_dragging: None,
shift: false,
slider_value: None,
scrollbar_drag_y: None,
@@ -1746,7 +1782,23 @@ fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
let label =
Text::option_name_value(Text::literal(option.name.clone()), Text::literal(value));
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
match pack.slider_position(o) {
Some(value) => Widget {
x: 0.0,
y: 0.0,
w: width,
h: 20.0,
kind: WKind::PackSlider {
label,
value,
pack: i,
option: o,
},
list: false,
row: 0,
},
None => button(0.0, 0.0, width, label, Act::NativePackOption(i, o)),
}
}
PageItem::Page(p) if p < pack.pages.len() => {
let label = Text::literal(format!("{}...", pack.pages[p].name));
@@ -1845,7 +1897,10 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
WKind::Button {
act: Act::NativePackOption(pack, option),
..
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
}
| WKind::PackSlider { pack, option, .. } => {
Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description)
}
WKind::Button {
act: Act::Push(Page::NativePackOptions(pack, at)),
..
@@ -2043,6 +2098,13 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
.collect()
}
impl OptionsUi {
/// No widget keeps the highlight a click gave it; a focus the keyboard
/// moved stays.
pub fn clear_click_focus(&mut self) {
if !self.focus_from_keyboard {
self.focus = None;
}
}
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
let label = Text::option_name_value(
@@ -2163,6 +2225,18 @@ impl OptionsUi {
}
events
}
/// Steps a Horizon pack's option slider one step forward or backward, and saves it.
fn step_pack_slider(s: &mut Settings, pack: usize, option: usize, forward: bool) -> Vec<Event> {
let stepped = s
.shader_packs
.get_mut(pack)
.is_some_and(|entry| entry.nudge_option(option, !forward));
if stepped {
vec![Event::NativePackOptions(pack)]
} else {
Vec::new()
}
}
/// Open a page whose Done/Escape returns directly to the calling screen.
pub fn open_at(&mut self, in_world: bool, page: Page) {
self.stack.clear();
@@ -2689,8 +2763,11 @@ impl OptionsUi {
Opt::PadMoveDeadzone,
Opt::PadLookDeadzone,
Opt::PadGlyphs,
Opt::PadMenuNav,
Opt::PadKeyboard,
Opt::PadPointerSpeed,
Opt::PadScrollSpeed,
Opt::PadRepeatDelay,
Opt::PadRepeatInterval,
]));
rows
}
@@ -3716,6 +3793,34 @@ impl OptionsUi {
dragging,
);
}
WKind::PackSlider {
label,
value,
pack,
option,
} => {
let held = self
.pack_dragging
.filter(|&(p, o, ..)| (p, o) == (*pack, *option));
let dragging = held.is_some();
ui::slider_ext(
batch,
atlas,
wd.x,
wd.y,
wd.w,
label,
held.map_or(*value, |h| h.2),
hover || dragging,
is_focused,
self.can_change_value && is_focused,
true,
dragging,
);
crate::widgets::last_narratable(|n| {
narrate_widget(n, s, wd, hover, list_key, framebuffer);
});
}
WKind::Text {
text,
color,
@@ -4015,6 +4120,16 @@ impl OptionsUi {
*at = ui::slider_value(wd.x, wd.w, x);
options.set_position(name, *at);
}
if let Some((pack, option, at, changed)) = &mut self.pack_dragging
&& let Some(wd) = widgets.iter().find(|wd| {
matches!(wd.kind, WKind::PackSlider { pack: p, option: o, .. }
if (p, o) == (*pack, *option))
})
&& let Some(entry) = s.shader_packs.get_mut(*pack)
{
*at = ui::slider_value(wd.x, wd.w, x);
*changed |= entry.set_slider_position(*option, *at);
}
if let Some(opt) = self.dragging
&& let Some(wd) = widgets
.iter()
@@ -4251,6 +4366,21 @@ impl OptionsUi {
}
self.shader_dragging = Some((name, at));
}
WKind::PackSlider { pack, option, .. } => {
self.can_change_value = true;
let Some(entry) = s.shader_packs.get_mut(pack) else {
break;
};
if self.shift {
// Its default, as a shift-click on a pack's button does; no drag follows.
entry.step_option(option, false, true);
events.extend([Event::ButtonClick, Event::NativePackOptions(pack)]);
break;
}
let at = ui::slider_value(wd.x, wd.w, x);
let changed = entry.set_slider_position(option, at);
self.pack_dragging = Some((pack, option, at, changed));
}
WKind::Slider { opt, .. } => {
self.can_change_value = true;
self.dragging = Some(opt);
@@ -4287,12 +4417,19 @@ impl OptionsUi {
}
// AbstractSliderButton suppresses the press sound and plays once on
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
let pack = self.pack_dragging.take();
let slider =
self.dragging.take().is_some() | self.shader_dragging.take().is_some() | pack.is_some();
let graphics_slider = self.graphics.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
vec![Event::ButtonClick, Event::Changed]
let mut events = vec![Event::ButtonClick, Event::Changed];
// A pack's option is saved, and the pack rebuilt, once per drag.
if let Some((pack, _, _, true)) = pack {
events.push(Event::NativePackOptions(pack));
}
events
} else if split_drop {
vec![Event::ButtonClick]
} else {
@@ -4390,6 +4527,10 @@ impl OptionsUi {
.map_or(150.0, |wd| wd.w);
return self.step_slider_option(s, opt, amount > 0.0, width);
}
Some(FocusId::PackSlider(pack, option)) => {
let (pack, option) = (*pack, *option);
return Self::step_pack_slider(s, pack, option, amount > 0.0);
}
Some(FocusId::Cycle(opt)) => {
let opt = *opt;
let mut events = vec![Event::ButtonClick];
@@ -4855,6 +4996,7 @@ impl OptionsUi {
match wd.kind {
WKind::Button { active: true, .. }
| WKind::Slider { .. }
| WKind::PackSlider { .. }
| WKind::LanguageRow { .. } => {
focusables.push((focus_id(i, wd), [wd.x, wd.y, wd.w, wd.h], wd.list));
}
@@ -4890,7 +5032,7 @@ impl OptionsUi {
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = matches!(next_id, FocusId::Slider(_));
self.can_change_value = matches!(next_id, FocusId::Slider(_) | FocusId::PackSlider(..));
if *in_list {
if rect[1] < top {
self.scroll -= (top - rect[1]) + 4.0;
@@ -4915,6 +5057,11 @@ impl OptionsUi {
.map_or(150.0, |wd| wd.w);
return self.step_slider_option(s, opt, forward, width);
}
if let Some(FocusId::PackSlider(pack, option)) = self.focus
&& self.can_change_value
{
return Self::step_pack_slider(s, pack, option, forward);
}
// Otherwise, navigate horizontally using 2D focus stepping
if let Some(fid) = &self.focus {
if let Some(cur_pos) = focusables.iter().position(|(id, ..)| id == fid) {
@@ -4992,7 +5139,7 @@ impl OptionsUi {
// 4. Enter / Space selection keys
if matches!(key, K::Enter | K::NumpadEnter | K::Space) {
match &self.focus {
Some(FocusId::Slider(_)) => {
Some(FocusId::Slider(_) | FocusId::PackSlider(..)) => {
self.can_change_value = !self.can_change_value;
return Vec::new();
}
@@ -5505,11 +5652,9 @@ mod tests {
id: "a".into(),
name: "A".into(),
description: String::new(),
kind: horizon_shader_api::OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
kind: horizon_shader_api::OptionKind::Choice {
values: vec!["x".into(), "y".into(), "z".into()],
default: 1,
},
}],
values: vec![1.0],
@@ -5550,10 +5695,10 @@ mod tests {
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values, [1.5]);
assert_eq!(s.shader_packs[0].values, [2.0]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
assert_eq!(s.shader_packs[0].values, [0.0]);
// Shift-click: the option's default.
ui.shift = true;
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
@@ -5567,6 +5712,116 @@ mod tests {
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
}
#[test]
fn a_horizon_packs_float_and_int_options_are_sliders_saved_on_release() {
use horizon_shader_api::{OptionKind, PackOption};
use winit::keyboard::KeyCode;
let atlas = atlas();
let size = (960.0, 540.0);
let option = |id: &str, kind| PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
};
let pack = crate::shader_pack_list::Entry {
file: "p".into(),
title: "P".into(),
kind: crate::shader_pack_list::Kind::Horizon,
options: vec![
option(
"a",
OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
},
),
option(
"b",
OptionKind::Int {
default: 2,
min: 0,
max: 4,
},
),
option("c", OptionKind::Bool { default: true }),
],
values: vec![1.0, 2.0, 1.0],
pages: Vec::new(),
};
let mut s = Settings {
shader_packs: vec![pack],
..Settings::default()
};
let mut ui = OptionsUi::default();
ui.show(Page::NativePackOptions(0, 0));
// A slider's track, from its left end to its right: (x of 0, x of 1, y).
let track = |ui: &mut OptionsUi, s: &Settings, want: usize| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
widgets
.into_iter()
.find(|w| matches!(w.kind, WKind::PackSlider { option, .. } if option == want))
.map(|w| (w.x + 4.0, w.x + w.w - 4.0, w.y + w.h / 2.0))
};
assert!(track(&mut ui, &s, 2).is_none(), "a bool stays a button");
let (left, right, y) = track(&mut ui, &s, 0).expect("the float's slider");
let at = |f: f32| left + f * (right - left);
// Pressed a fifth of the way: 0.4 of 2, the nearest step 0.5. Nothing is saved yet.
let events = ui.click(&mut s, &atlas, at(0.2), y, size, [854, 480], false, false);
assert!(!events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values[0], 0.5);
// Dragged past the right end: the maximum.
ui.click(
&mut s,
&atlas,
right + 30.0,
y,
size,
[854, 480],
true,
false,
);
assert_eq!(s.shader_packs[0].values[0], 2.0);
// Released: saved once, which reloads the pack.
let events = ui.release();
let saves = events.iter().filter(|e| **e == Event::NativePackOptions(0));
assert_eq!(saves.count(), 1);
// A press and release that leave the value as it was save nothing.
ui.click(&mut s, &atlas, right, y, size, [854, 480], false, false);
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
// Shift-click: the default at once.
ui.shift = true;
let events = ui.click(&mut s, &atlas, at(0.9), y, size, [854, 480], false, false);
ui.shift = false;
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values[0], 1.0);
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
// The int takes whole numbers: 0.6 of 0..4 is 2.4, so 2, unchanged; 0.7 is 3.
let (left, right, y) = track(&mut ui, &s, 1).expect("the int's slider");
let at = |f: f32| left + f * (right - left);
ui.click(&mut s, &atlas, at(0.6), y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values[1], 2.0);
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
ui.click(&mut s, &atlas, at(0.7), y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values[1], 3.0);
assert!(ui.release().contains(&Event::NativePackOptions(0)));
// From the keyboard: the arrows step a focused slider, saving each step, up to its end.
ui.focus = Some(FocusId::PackSlider(0, 1));
ui.focus_from_keyboard = true;
ui.can_change_value = true;
let events = ui.key(&mut s, &atlas, KeyCode::ArrowRight, None);
assert_eq!(events, [Event::NativePackOptions(0)]);
assert_eq!(s.shader_packs[0].values[1], 4.0);
assert!(ui.key(&mut s, &atlas, KeyCode::ArrowRight, None).is_empty());
assert_eq!(s.shader_packs[0].values[1], 4.0);
ui.key(&mut s, &atlas, KeyCode::ArrowLeft, None);
assert_eq!(s.shader_packs[0].values[1], 3.0);
}
#[test]
fn resource_reload_error_blocks_options_until_dismissed() {
use winit::keyboard::KeyCode;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,67 @@
//! The controller's menu cursor in inventories: a crosshair whose centre is
//! the point it clicks. Horizon's own art (pad_cursor.png).
/// The atlas sprite.
pub const SPRITE: &str = "horizon/pad_cursor";
/// The texture's size in texels, and the size it's drawn at in GUI pixels
/// (an item's).
pub const SIZE: u32 = 32;
pub const DRAWN: f32 = 16.0;
const PNG: &[u8] = include_bytes!("pad_cursor.png");
/// The texture as `SIZE` square RGBA texels.
pub fn pixels() -> Vec<u8> {
decode(PNG).expect("pad_cursor.png is a 32x32 PNG")
}
fn decode(bytes: &[u8]) -> Option<Vec<u8>> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let info = reader.info();
if (info.width, info.height) != (SIZE, SIZE) {
return None;
}
let mut buf = vec![0; reader.output_buffer_size()?];
let frame = reader.next_frame(&mut buf).ok()?;
let channels = frame.color_type.samples();
Some(
buf.chunks(channels)
.take((SIZE * SIZE) as usize)
.flat_map(|p| match channels {
4 => [p[0], p[1], p[2], p[3]],
3 => [p[0], p[1], p[2], 255],
2 => [p[0], p[0], p[0], p[1]],
_ => [p[0], p[0], p[0], 255],
})
.collect(),
)
}
/// Where to draw the cursor (top-left, GUI pixels) so its centre lands on
/// the GUI point `at`.
pub fn origin(at: (f32, f32)) -> (f32, f32) {
(at.0 - DRAWN / 2.0, at.1 - DRAWN / 2.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_cursor_is_a_crosshair_centred_on_its_point() {
let px = pixels();
assert_eq!(px.len(), (SIZE * SIZE * 4) as usize);
let alpha = |x: u32, y: u32| px[((y * SIZE + x) * 4 + 3) as usize];
let middle = (SIZE / 2 - 1)..=(SIZE / 2);
assert!(
middle
.clone()
.any(|x| middle.clone().any(|y| alpha(x, y) > 0)),
"the clicked point sits on the crosshair"
);
assert_eq!(alpha(0, 0), 0, "corners are clear: a plus, not a square");
assert_eq!(origin((100.0, 50.0)), (92.0, 42.0));
}
}
@@ -26,6 +26,8 @@ use std::{
pub use horizon_client_core::particle_type::ParticleType;
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
mod native;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
#[derive(Clone, Debug)]
@@ -212,6 +214,9 @@ enum Light {
#[derive(Clone, Copy, Debug, PartialEq)]
enum Behaviour {
Base,
Controlled {
translucent: bool,
},
Mod {
size: [f32; 2],
alpha: [f32; 2],
@@ -505,6 +510,7 @@ pub struct Particle {
pre_tick: bool,
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
random: LegacyRandom,
controller: Option<Box<dyn native::Controller>>,
}
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
let w = f64::from(w) / 2.0;
@@ -841,8 +847,37 @@ impl Particle {
pre_tick: false,
frame_ticks: 0,
random: LegacyRandom::new(0),
controller: None,
}
}
/// The normal age/removal test and previous pose, without vanilla motion.
/// Returns false when this tick expires the particle.
pub fn advance_age(&mut self) -> bool {
self.previous = self.position;
self.o_roll = self.roll;
let alive = self.age < self.lifetime;
self.age = self.age.saturating_add(1);
self.removed |= !alive;
alive
}
pub fn remove(&mut self) {
self.removed = true;
}
/// Hide geometry while retaining the particle's lifetime and controller.
pub fn set_visible(&mut self, visible: bool) {
self.no_render = !visible;
}
/// The particle's own random, distinct from the engine and level generators.
pub fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
}
pub fn set_full_bright(&mut self, full_bright: bool) {
self.light = if full_bright {
Light::FullBright
} else {
Light::World
};
}
/// `Particle(level, x, y, z, xa, ya, za)`: jittered, normalized initial velocity.
fn moving(random: &mut LegacyRandom, ty: ParticleType, p: [f64; 3], v: [f64; 3]) -> Self {
let mut particle = Self::base_moving(random, ty, p, v);
@@ -1420,6 +1455,10 @@ impl ParticleEngine {
atlas: &HashMap<String, Rect>,
translucent: &HashSet<String>,
) {
// Controllers' numeric sprite-set handles belong to this resource
// generation. Discard them rather than selecting another set after reload.
self.particles.retain(|p| p.controller.is_none());
self.pending.retain(|p| p.controller.is_none());
self.atlas = atlas
.iter()
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
@@ -1584,6 +1623,45 @@ impl ParticleEngine {
pub fn push(&mut self, particle: Particle) {
self.pending.push(particle);
}
/// Create a named billboard with custom compiled behavior and no vanilla
/// motion. Like `add`, respects camera distance and the Particles setting.
/// Configure its public fields, then `push` it to the ordinary engine queue.
/// Missing sprite sets return None. The constructor has the ordinary
/// stationary SingleQuadParticle random size/lifetime and no velocity draws.
pub fn controlled<C: ParticleController>(
&mut self,
name: &str,
position: [f64; 3],
translucent: bool,
controller: C,
) -> Option<Particle> {
let minimal = self.calculate_particle_level(false) == 2;
let distance: f64 = position
.iter()
.zip(self.camera)
.map(|(p, c)| (p - c).powi(2))
.sum();
if minimal || !distance.is_finite() || distance > 1024.0 {
return None;
}
let (set, _) = *self.mod_sets.get(name)?;
let mut random = LegacyRandom::new(self.seeds.next_long());
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
particle.random = random;
particle.set = Some(set);
particle.sprite = Sprite::Index(*self.set_sprites.get(set as usize)?.first()?);
particle.behaviour = Behaviour::Controlled { translucent };
particle.controller = Some(Box::new(controller));
Some(particle)
}
/// Choose a frame from this particle's declared sprite set.
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
&& !sprites.is_empty()
{
particle.sprite = Sprite::Index(sprites[frame.min(sprites.len() - 1)]);
}
}
/// Random generator shared by particle constructors and spawners.
pub fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
@@ -3710,6 +3788,20 @@ impl ParticleEngine {
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
match p.behaviour {
Behaviour::Base => p.base_tick(level),
Behaviour::Controlled { .. } => {
if let Some(mut controller) = p.controller.take() {
let update = controller.tick(p, level);
p.controller = Some(controller);
if let Some(frame) = update.frame {
self.set_sprite_frame(p, frame);
}
for (options, pos, velocity) in update.spawned {
self.add(&options, pos, velocity);
}
} else {
p.remove();
}
}
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
@@ -4627,6 +4719,7 @@ impl ParticleEngine {
Sprite::Index(i) => (
self.rects.get(*i as usize).copied().flatten(),
p.ty.translucent_layer()
|| matches!(p.behaviour, Behaviour::Controlled { translucent: true })
|| matches!(p.behaviour, Behaviour::Mod { alpha, .. } if alpha != [1.0; 2]),
),
Sprite::Named(name) => match self.atlas.get(name) {
@@ -0,0 +1,179 @@
//! Opt-in, compiled particle behavior. The ordinary engine still owns the
//! particle, its sprite set, interpolation, rendering and total count limits.
use super::{Particle, ParticleLevel, ParticleOptions};
use std::fmt::Debug;
/// Requests made by one particle tick, submitted through normal spawn limits.
#[derive(Default)]
pub struct ParticleUpdate {
/// An index in this particle's declared sprite set. Out-of-range is clamped.
pub frame: Option<usize>,
pub spawned: Vec<(ParticleOptions, [f64; 3], [f64; 3])>,
}
/// Owned behavior for a compiled native particle. Cloning a particle clones its
/// controller state. Do not retain world references; inspect `level` each tick.
pub trait ParticleController: Clone + Debug + Send + Sync + 'static {
/// Call `particle.advance_age()` for ordinary lifetime/interpolation, or
/// explicitly maintain those fields for an overridden source tick.
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
}
pub(super) trait Controller: Debug + Send + Sync {
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
fn clone_box(&self) -> Box<dyn Controller>;
}
impl<T: ParticleController> Controller for T {
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
ParticleController::tick(self, particle, level)
}
fn clone_box(&self) -> Box<dyn Controller> {
Box::new(self.clone())
}
}
impl Clone for Box<dyn Controller> {
fn clone(&self) -> Self {
self.clone_box()
}
}
#[cfg(test)]
mod tests {
use super::super::*;
use horizon_world::{
DATA_VERSION,
collision::CachedCollisionRegistry,
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
};
#[derive(Clone, Debug)]
struct Moving(u32);
impl ParticleController for Moving {
fn tick(&mut self, p: &mut Particle, _: &ParticleLevel<'_>) -> ParticleUpdate {
if p.advance_age() {
self.0 += 1;
p.position[0] += f64::from(self.0);
}
ParticleUpdate {
frame: Some(self.0 as usize),
..Default::default()
}
}
}
fn engine() -> ParticleEngine {
let mut e = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
e.mod_sets.insert("example:orbit".into(), (0, None));
e.set_sprites.push(vec![0, 1]);
e.rects = vec![
Some(Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16
});
2
];
e
}
#[test]
fn controlled_particle_keeps_owned_motion_sprite_interpolation_and_expiry() {
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(vec![Tag::String("minecraft:air".into())]),
),
("defaults", Tag::compound([("minecraft:air", Tag::Int(0))])),
]))
.unwrap();
let cached = CachedCollisionRegistry::from_nbt(
&Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
(
"states",
Tag::List(vec![Tag::String("minecraft:air".into())]),
),
("shapes", Tag::List(vec![])),
("cached_shapes", Tag::IntArray(vec![-1])),
]),
&registry,
)
.unwrap();
let collision = BlockCollisionRegistry::new(std::sync::Arc::new(cached)).unwrap();
let facts = BlockFacts {
states: vec![],
air: BlockStateId(0),
woods: HashMap::new(),
};
let world = World::new(BlockStateId(0), 0);
let level = ParticleLevel {
world: &world,
facts: &facts,
collision: &collision,
player: None,
};
let mut e = engine();
let mut p = e
.controlled("example:orbit", [0.0; 3], true, Moving(0))
.unwrap();
p.lifetime = 2;
p.rgba[3] = 0.5;
p.velocity = [99.0; 3]; // no vanilla drift may leak into the custom tick
let mut copy = p.clone();
e.tick_particle(&mut p, &level);
e.tick_particle(&mut p, &level);
e.tick_particle(&mut copy, &level);
assert_eq!((p.position, p.previous), ([3.0, 0.0, 0.0], [1.0, 0.0, 0.0]));
assert_eq!(copy.position, [1.0, 0.0, 0.0]); // controller state cloned independently
assert!(matches!(p.sprite, Sprite::Index(1))); // clamped frame
p.size = 0.1;
e.particles.push(p.clone());
let mut vertices = Vec::new();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert_eq!(vertices.len(), 4);
assert!(vertices.iter().all(
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
));
e.particles[0].set_visible(false);
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert!(vertices.is_empty());
assert_eq!(e.particles[0].age, 2);
e.pending.push(copy);
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
assert!(e.pending.is_empty());
vertices.clear();
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
assert!(vertices.is_empty());
e.tick_particle(&mut p, &level);
assert!(p.removed);
assert_eq!(p.position, [3.0, 0.0, 0.0]);
}
#[test]
fn controlled_spawn_respects_distance_settings_and_declared_sprite_sets() {
let mut e = engine();
assert!(
e.controlled("example:missing", [0.0; 3], true, Moving(0))
.is_none()
);
assert!(
e.controlled("example:orbit", [33.0, 0.0, 0.0], true, Moving(0))
.is_none()
);
assert!(
e.controlled("example:orbit", [f64::NAN, 0.0, 0.0], true, Moving(0))
.is_none()
);
assert!(
e.controlled("example:orbit", [32.0, 0.0, 0.0], true, Moving(0))
.is_some()
);
e.particle_level = 2;
assert!(
e.controlled("example:orbit", [0.0; 3], true, Moving(0))
.is_none()
);
}
}
+7 -2
View File
@@ -651,7 +651,9 @@ impl Settings {
}
pub fn save(&self, path: &Path) {
self.describe_for_crashes();
let _ = fs::write(path, self.serialize());
if let Err(e) = fs::write(path, self.serialize()) {
log::warn!("Horizon: options.txt is not saved: {e}");
}
if let Some(dir) = path.parent() {
self.horizon.save(dir);
self.controller.save(dir);
@@ -803,13 +805,16 @@ pub enum Opt {
PadAdaptiveTriggers,
PadLightbar,
PadLookSensitivity,
PadPointerSpeed,
PadScrollSpeed,
PadRepeatDelay,
PadRepeatInterval,
PadInvertX,
PadInvertY,
PadToggleSneak,
PadMoveDeadzone,
PadLookDeadzone,
PadGlyphs,
PadMenuNav,
PadKeyboard,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
@@ -53,6 +53,47 @@ impl Entry {
true
}
/// Where option `i` sits on its slider, 0 to 1: float and int options are sliders.
pub fn slider_position(&self, i: usize) -> Option<f32> {
slider_position(&self.options.get(i)?.kind, *self.values.get(i)?)
}
/// Option `i` at the value nearest the slider position `at`. Whether its value changed.
pub fn set_slider_position(&mut self, i: usize, at: f32) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
let Some(new) = slider_value(&o.kind, at) else {
return false;
};
let changed = new != *v;
*v = new;
changed
}
/// Option `i`'s slider one step on (`back`: one before), stopping at its ends. Whether its
/// value changed.
pub fn nudge_option(&mut self, i: usize, back: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
let dir = if back { -1.0 } else { 1.0 };
let new = match &o.kind {
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let n = ((*v - min) / step).round() + dir;
on_f32((min + n * step).clamp(min, max))
}
OptionKind::Int { min, max, .. } => {
(v.round() + dir).clamp(f64::from(*min), f64::from(*max))
}
_ => return false,
};
let changed = new != *v;
*v = new;
changed
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
@@ -209,6 +250,42 @@ fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
}
}
/// Where `v` sits between the option's minimum and maximum, 0 to 1; `None` for the options
/// that are buttons.
fn slider_position(kind: &OptionKind, v: f64) -> Option<f32> {
let (min, max) = match kind {
OptionKind::Float { min, max, .. } => (f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => (f64::from(*min), f64::from(*max)),
_ => return None,
};
Some(((v - min) / (max - min)).clamp(0.0, 1.0) as f32)
}
/// The option's value nearest the slider position `at`: a float on its steps from the
/// minimum (the last one the maximum, when the range isn't a whole number of steps), an int
/// whole.
fn slider_value(kind: &OptionKind, at: f32) -> Option<f64> {
let at = f64::from(at.clamp(0.0, 1.0));
match kind {
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
let steps = ((max - min) / step - 1e-6).ceil();
Some(on_f32((min + (at * steps).round() * step).min(max)))
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
Some((min + at * (max - min)).round())
}
_ => None,
}
}
/// `v` as the `f32` the pack's `override` gets: a step of 0.05 is no f32, and 20 of them would
/// otherwise miss a default of 1 by a hair.
fn on_f32(v: f64) -> f64 {
f64::from(v as f32)
}
/// The value as the settings page shows it.
pub fn show_value(kind: &OptionKind, v: f64) -> String {
match kind {
@@ -324,4 +401,65 @@ mod tests {
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn sliders_take_the_nearest_step_and_stop_at_the_ends() {
let float = |default, max, step| OptionKind::Float {
default,
min: 0.0,
max,
step,
};
let int = OptionKind::Int {
default: 2,
min: 0,
max: 10,
};
let mut entry = Entry {
file: "p".into(),
title: "P".into(),
kind: Kind::Horizon,
options: vec![
option("a", float(1.0, 2.0, 0.05)),
option("b", int),
option("c", OptionKind::Bool { default: true }),
option("d", float(0.0, 1.0, 0.3)),
],
values: vec![1.0, 2.0, 1.0, 0.0],
pages: Vec::new(),
};
assert_eq!(entry.slider_position(0), Some(0.5));
assert_eq!(entry.slider_position(1), Some(0.2));
// A bool (and a choice) stays a button.
assert_eq!(entry.slider_position(2), None);
assert!(!entry.set_slider_position(2, 0.0));
assert_eq!(entry.values[2], 1.0);
// 0.26 of 0..2 is 0.52: the step below.
assert!(entry.set_slider_position(0, 0.26));
assert_eq!(show_value(&entry.options[0].kind, entry.values[0]), "0.50");
// Twenty steps of 0.05 are the default of 1 exactly, so it isn't saved.
assert!(entry.set_slider_position(0, 0.5));
assert_eq!(entry.values[0], 1.0);
assert!(!entry.set_slider_position(0, 0.505));
// 5.7 of 0..10 is 6.
assert!(entry.set_slider_position(1, 0.57));
assert_eq!(entry.values[1], 6.0);
// A range that isn't a whole number of steps still reaches its maximum.
entry.set_slider_position(3, 1.0);
assert_eq!(entry.values[3], 1.0);
entry.set_slider_position(3, 0.6);
assert_eq!(entry.values[3], f64::from(0.6_f32));
// The arrow keys: a step at a time, not round past the ends.
entry.values[0] = 2.0;
assert!(!entry.nudge_option(0, false));
assert_eq!(entry.values[0], 2.0);
assert!(entry.nudge_option(0, true));
assert_eq!(entry.values[0], f64::from(1.95_f32));
entry.values[1] = 0.0;
assert!(!entry.nudge_option(1, true));
assert!(entry.nudge_option(1, false));
assert_eq!(entry.values[1], 1.0);
assert!(!entry.nudge_option(2, false));
}
}
@@ -10,7 +10,6 @@
"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": "الصحة",
@@ -19,8 +18,6 @@
"horizon.options.glyphs.xbox": "اكس بوكس",
"horizon.options.glyphs.playstation": "بلاي ستيشن",
"horizon.options.glyphs.nintendo": "نينتندو",
"horizon.options.menu_focus": "التركيز",
"horizon.options.menu_pointer": "المؤشر",
"horizon.options.keyboard.auto": "تلقائي",
"horizon.options.keyboard.steam": "البخار",
"horizon.options.unavailable": "هذه الميزة غير متوفرة بعد في Horizon.",
@@ -10,14 +10,11 @@
"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.menu_focus": "Фокус",
"horizon.options.menu_pointer": "показалец",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.steam": "Пара",
"horizon.options.unavailable": "Тази функция все още не е налична в Horizon.",
@@ -10,13 +10,10 @@
"horizon.options.move_deadzone": "Mrtvá zóna pohybu",
"horizon.options.look_deadzone": "Mrtvá zóna pohledu",
"horizon.options.button_glyphs": "Glyfy tlačítek",
"horizon.options.menu_navigation": "Navigace v menu",
"horizon.options.on_screen_keyboard": "Klávesnice na obrazovce",
"horizon.options.chat_delay": "Zpoždění chatu",
"horizon.options.lightbar.health": "Zdraví",
"horizon.options.lightbar.dimension": "Dimenze",
"horizon.options.menu_focus": "Zaměřte se",
"horizon.options.menu_pointer": "Ukazatel",
"horizon.options.keyboard.horizon": "Horizont",
"horizon.options.keyboard.steam": "Pára",
"horizon.options.unavailable": "Tato funkce zatím není v Horizon k dispozici.",
@@ -8,11 +8,9 @@
"horizon.options.move_deadzone": "Dødzone for bevægelse",
"horizon.options.look_deadzone": "Dødzone for kig",
"horizon.options.button_glyphs": "Knap Glyfer",
"horizon.options.menu_navigation": "Menu navigation",
"horizon.options.on_screen_keyboard": "Skærmtastatur",
"horizon.options.chat_delay": "Chatforsinkelse",
"horizon.options.lightbar.health": "Sundhed",
"horizon.options.menu_focus": "Fokus",
"horizon.options.keyboard.steam": "Damp",
"horizon.options.unavailable": "Denne funktion er endnu ikke tilgængelig i Horizon.",
"horizon.options.search": "Søgeindstillinger...",
@@ -11,7 +11,6 @@
"horizon.options.move_deadzone": "Totzone der Bewegung",
"horizon.options.look_deadzone": "Totzone des Blicks",
"horizon.options.button_glyphs": "Tastensymbole",
"horizon.options.menu_navigation": "Menünavigation",
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
"horizon.options.chat_delay": "Chatverzögerung",
"horizon.options.lightbar.health": "Gesundheit",
@@ -20,8 +19,6 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Zeiger",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -9,14 +9,11 @@
"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.menu_focus": "Εστίαση",
"horizon.options.menu_pointer": "Δείκτης",
"horizon.options.keyboard.auto": "Αυτόματο",
"horizon.options.unavailable": "Αυτή η δυνατότητα δεν είναι ακόμη διαθέσιμη στο Horizon.",
"horizon.options.search": "Ρυθμίσεις αναζήτησης...",
@@ -11,7 +11,6 @@
"horizon.options.move_deadzone": "Move Deadzone",
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
@@ -20,8 +19,6 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -522,5 +519,10 @@
"horizon.extras.cosmetics_auras.tooltip": "Auras and trails other players wear. Off by default.",
"horizon.extras.cosmetics.shown": "Shown",
"horizon.extras.cosmetics.hidden": "Hidden",
"horizon.extras.cosmetics.name_tags": "Name tags only"
"horizon.extras.cosmetics.name_tags": "Name tags only",
"horizon.options.pointer_speed": "Menu Pointer Speed",
"horizon.options.scroll_speed": "Menu Scroll Speed",
"horizon.options.repeat_delay": "Navigation Repeat Delay",
"horizon.options.repeat_interval": "Navigation Repeat Interval",
"horizon.options.milliseconds": "%s ms"
}
@@ -10,7 +10,6 @@
"horizon.options.move_deadzone": "Zona muerta de movimiento",
"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.chat_delay": "Retardo del chat",
"horizon.options.lightbar.health": "Salud",
@@ -19,8 +18,6 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Foco",
"horizon.options.menu_pointer": "Puntero",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -10,14 +10,11 @@
"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.menu_focus": "تمرکز کنید",
"horizon.options.menu_pointer": "اشاره گر",
"horizon.options.keyboard.auto": "خودکار",
"horizon.options.keyboard.steam": "بخار",
"horizon.options.unavailable": "این ویژگی هنوز در Horizon موجود نیست.",
@@ -9,13 +9,10 @@
"horizon.options.move_deadzone": "Liikkeen kuollut alue",
"horizon.options.look_deadzone": "Katseen kuollut alue",
"horizon.options.button_glyphs": "Painikkeiden kuviot",
"horizon.options.menu_navigation": "Valikkonavigointi",
"horizon.options.on_screen_keyboard": "Näyttönäppäimistö",
"horizon.options.chat_delay": "Chatin viive",
"horizon.options.lightbar.health": "Terveys",
"horizon.options.lightbar.dimension": "Mitat",
"horizon.options.menu_focus": "Keskity",
"horizon.options.menu_pointer": "Osoitin",
"horizon.options.unavailable": "Tämä ominaisuus ei ole vielä saatavilla Horizonissa.",
"horizon.options.search": "Hakuasetukset...",
"horizon.pages.mods": "Modit",
@@ -10,11 +10,9 @@
"horizon.options.move_deadzone": "Zone morte du déplacement",
"horizon.options.look_deadzone": "Zone morte de la vue",
"horizon.options.button_glyphs": "Symboles des boutons",
"horizon.options.menu_navigation": "Navigation dans les menus",
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
"horizon.options.chat_delay": "Délai du tchat",
"horizon.options.lightbar.health": "Santé",
"horizon.options.menu_pointer": "Pointeur",
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
"horizon.options.search": "Rechercher une option...",
"horizon.pages.shader_packs": "Packs de shaders",
@@ -443,5 +441,10 @@
"horizon.repro.bundle": "Archive de reproduction : %s",
"horizon.repro.error": "Reproduction : %s",
"horizon.repro.no_recording": "Il n'y a pas encore d'enregistrement à exporter.",
"horizon.repro.replay_started": "Lecture démarrée : %s"
"horizon.repro.replay_started": "Lecture démarrée : %s",
"horizon.options.pointer_speed": "Vitesse du pointeur des menus",
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
"horizon.options.repeat_delay": "Délai de répétition de navigation",
"horizon.options.repeat_interval": "Intervalle de répétition de navigation",
"horizon.options.milliseconds": "%s ms"
}
@@ -10,14 +10,11 @@
"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.menu_focus": "פוקוס",
"horizon.options.menu_pointer": "מצביע",
"horizon.options.keyboard.auto": "אוטומטי",
"horizon.options.keyboard.steam": "קיטור",
"horizon.options.unavailable": "תכונה זו עדיין לא זמינה ב-Horizon.",
@@ -10,7 +10,6 @@
"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": "स्वास्थ्य",
@@ -19,8 +18,6 @@
"horizon.options.glyphs.xbox": "एक्सबॉक्स",
"horizon.options.glyphs.playstation": "प्लेस्टेशन",
"horizon.options.glyphs.nintendo": "निनटेंडो",
"horizon.options.menu_focus": "फोकस",
"horizon.options.menu_pointer": "पॉइंटर",
"horizon.options.keyboard.auto": "ऑटो",
"horizon.options.keyboard.horizon": "क्षितिज",
"horizon.options.keyboard.steam": "स्टीम",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Mrtva zona kretanja",
"horizon.options.look_deadzone": "Mrtva zona pogleda",
"horizon.options.button_glyphs": "Simboli gumba",
"horizon.options.menu_navigation": "Navigacija izbornikom",
"horizon.options.on_screen_keyboard": "Zaslonska tipkovnica",
"horizon.options.chat_delay": "Odgoda chata",
"horizon.options.lightbar.health": "Zdravlje",
"horizon.options.lightbar.dimension": "Dimenzija",
"horizon.options.glyphs.auto": "Automatski",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Pokazivač",
"horizon.options.keyboard.auto": "Automatski",
"horizon.options.unavailable": "Ova značajka još nije dostupna u Horizonu.",
"horizon.options.search": "Pretraži postavke...",
@@ -9,13 +9,10 @@
"horizon.options.move_deadzone": "Mozgás holtzónája",
"horizon.options.look_deadzone": "Nézet holtzónája",
"horizon.options.button_glyphs": "Gomb jelek",
"horizon.options.menu_navigation": "Menü navigáció",
"horizon.options.on_screen_keyboard": "Képernyő-billentyűzet",
"horizon.options.chat_delay": "Chat késleltetés",
"horizon.options.lightbar.health": "Egészség",
"horizon.options.lightbar.dimension": "Méret",
"horizon.options.menu_focus": "Fókusz",
"horizon.options.menu_pointer": "Mutató",
"horizon.options.keyboard.horizon": "Horizont",
"horizon.options.unavailable": "Ez a funkció még nem érhető el a Horizonban.",
"horizon.options.search": "Keresési beállítások...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Zona Mati Gerakan",
"horizon.options.look_deadzone": "Zona Mati Pandangan",
"horizon.options.button_glyphs": "Simbol Tombol",
"horizon.options.menu_navigation": "Navigasi Menu",
"horizon.options.on_screen_keyboard": "Papan Ketik Layar",
"horizon.options.chat_delay": "Penundaan Obrolan",
"horizon.options.lightbar.health": "Nyawa",
"horizon.options.lightbar.dimension": "Dimensi",
"horizon.options.glyphs.auto": "Otomatis",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Penunjuk",
"horizon.options.keyboard.auto": "Otomatis",
"horizon.options.unavailable": "Fitur ini belum tersedia di Horizon.",
"horizon.options.search": "Cari pengaturan...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Zona morta del movimento",
"horizon.options.look_deadzone": "Zona morta della visuale",
"horizon.options.button_glyphs": "Glifi dei pulsanti",
"horizon.options.menu_navigation": "Navigazione nel menu",
"horizon.options.on_screen_keyboard": "Tastiera su schermo",
"horizon.options.chat_delay": "Ritardo nella chat",
"horizon.options.lightbar.health": "Salute",
"horizon.options.lightbar.dimension": "Dimensione",
"horizon.options.glyphs.auto": "Automatico",
"horizon.options.menu_focus": "Concentrarsi",
"horizon.options.menu_pointer": "Puntatore",
"horizon.options.keyboard.auto": "Automatico",
"horizon.options.keyboard.steam": "Vapore",
"horizon.options.unavailable": "Questa funzionalità non è ancora disponibile in Horizon.",
@@ -10,7 +10,6 @@
"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": "健康",
@@ -18,8 +17,6 @@
"horizon.options.glyphs.auto": "自動",
"horizon.options.glyphs.playstation": "プレイステーション",
"horizon.options.glyphs.nintendo": "任天堂",
"horizon.options.menu_focus": "フォーカス",
"horizon.options.menu_pointer": "ポインタ",
"horizon.options.keyboard.auto": "自動",
"horizon.options.keyboard.steam": "蒸気",
"horizon.options.unavailable": "この機能は Horizon ではまだ利用できません。",
@@ -10,14 +10,11 @@
"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.menu_focus": "포커스",
"horizon.options.menu_pointer": "포인터",
"horizon.options.keyboard.auto": "자동",
"horizon.options.unavailable": "이 기능은 아직 Horizon에서 사용할 수 없습니다.",
"horizon.options.search": "설정 검색...",
@@ -10,13 +10,10 @@
"horizon.options.move_deadzone": "Judėjimo negyvoji zona",
"horizon.options.look_deadzone": "Žvilgsnio negyvoji zona",
"horizon.options.button_glyphs": "Mygtukų Glifai",
"horizon.options.menu_navigation": "Meniu naršymas",
"horizon.options.on_screen_keyboard": "Ekrano klaviatūra",
"horizon.options.chat_delay": "Pokalbio delsimas",
"horizon.options.lightbar.health": "Sveikata",
"horizon.options.lightbar.dimension": "Matmenys",
"horizon.options.menu_focus": "Fokusas",
"horizon.options.menu_pointer": "Rodyklė",
"horizon.options.keyboard.horizon": "Horizontas",
"horizon.options.keyboard.steam": "Garas",
"horizon.options.unavailable": "Ši funkcija Horizonte dar nepasiekiama.",
@@ -10,13 +10,11 @@
"horizon.options.move_deadzone": "Dode zone voor bewegen",
"horizon.options.look_deadzone": "Dode zone voor kijken",
"horizon.options.button_glyphs": "Knop-glyphs",
"horizon.options.menu_navigation": "Menunavigatie",
"horizon.options.on_screen_keyboard": "Schermtoetsenbord",
"horizon.options.chat_delay": "Chatvertraging",
"horizon.options.lightbar.health": "Gezondheid",
"horizon.options.lightbar.dimension": "Afmeting",
"horizon.options.glyphs.auto": "Automatisch",
"horizon.options.menu_pointer": "Aanwijzer",
"horizon.options.keyboard.auto": "Automatisch",
"horizon.options.keyboard.steam": "Stoom",
"horizon.options.unavailable": "Deze functie is nog niet beschikbaar in Horizon.",
@@ -9,13 +9,10 @@
"horizon.options.move_deadzone": "Dødsone for bevegelse",
"horizon.options.look_deadzone": "Dødsone for blikk",
"horizon.options.button_glyphs": "Knappetegn",
"horizon.options.menu_navigation": "Menynavigering",
"horizon.options.on_screen_keyboard": "Skjermtastatur",
"horizon.options.chat_delay": "Chat-forsinkelse",
"horizon.options.lightbar.health": "Helse",
"horizon.options.lightbar.dimension": "Dimensjon",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Peker",
"horizon.options.unavailable": "Denne funksjonen er ennå ikke tilgjengelig i Horizon.",
"horizon.options.search": "Søkeinnstillinger...",
"horizon.pages.shader_packs": "Shader-pakker",
@@ -10,7 +10,6 @@
"horizon.options.move_deadzone": "Martwa strefa ruchu",
"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.chat_delay": "Opóźnienie czatu",
"horizon.options.lightbar.health": "Zdrowie",
@@ -19,8 +18,6 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Wskaźnik",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -10,15 +10,12 @@
"horizon.options.move_deadzone": "Zona morta do movimento",
"horizon.options.look_deadzone": "Zona morta da câmera",
"horizon.options.button_glyphs": "Glifos de botão",
"horizon.options.menu_navigation": "Navegação no menu",
"horizon.options.on_screen_keyboard": "Teclado na tela",
"horizon.options.chat_delay": "Atraso no bate-papo",
"horizon.options.lightbar.health": "Saúde",
"horizon.options.lightbar.dimension": "Dimensão",
"horizon.options.glyphs.auto": "Automático",
"horizon.options.glyphs.playstation": "Playstation",
"horizon.options.menu_focus": "Foco",
"horizon.options.menu_pointer": "Ponteiro",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.keyboard.horizon": "Horizonte",
"horizon.options.keyboard.steam": "Vapor",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Zona morta de movimento",
"horizon.options.look_deadzone": "Zona morta de visão",
"horizon.options.button_glyphs": "Símbolos dos botões",
"horizon.options.menu_navigation": "Navegação nos menus",
"horizon.options.on_screen_keyboard": "Teclado no ecrã",
"horizon.options.chat_delay": "Atraso do chat",
"horizon.options.lightbar.health": "Vida",
"horizon.options.lightbar.dimension": "Dimensão",
"horizon.options.glyphs.auto": "Automático",
"horizon.options.menu_focus": "Foco",
"horizon.options.menu_pointer": "Ponteiro",
"horizon.options.keyboard.auto": "Automático",
"horizon.options.unavailable": "Esta funcionalidade ainda não está disponível no Horizon.",
"horizon.options.search": "Pesquisar definições...",
@@ -10,13 +10,10 @@
"horizon.options.move_deadzone": "Zona moartă a mișcării",
"horizon.options.look_deadzone": "Zona moartă a privirii",
"horizon.options.button_glyphs": "Glife de buton",
"horizon.options.menu_navigation": "Navigare prin meniu",
"horizon.options.on_screen_keyboard": "Tastatură pe ecran",
"horizon.options.chat_delay": "Întârziere prin chat",
"horizon.options.lightbar.health": "Sănătate",
"horizon.options.lightbar.dimension": "Dimensiunea",
"horizon.options.menu_focus": "Concentrează-te",
"horizon.options.menu_pointer": "Indicator",
"horizon.options.keyboard.steam": "Abur",
"horizon.options.unavailable": "Această funcție nu este încă disponibilă în Horizon.",
"horizon.options.search": "Setări de căutare...",
@@ -10,7 +10,6 @@
"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": "Здоровье",
@@ -19,8 +18,6 @@
"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",
@@ -10,13 +10,10 @@
"horizon.options.move_deadzone": "Mŕtva zóna pohybu",
"horizon.options.look_deadzone": "Mŕtva zóna pohľadu",
"horizon.options.button_glyphs": "Glyfy tlačidiel",
"horizon.options.menu_navigation": "Navigácia v ponuke",
"horizon.options.on_screen_keyboard": "Klávesnica na obrazovke",
"horizon.options.chat_delay": "Oneskorenie rozhovoru",
"horizon.options.lightbar.health": "Zdravie",
"horizon.options.lightbar.dimension": "Rozmer",
"horizon.options.menu_focus": "Zamerajte sa",
"horizon.options.menu_pointer": "Ukazovateľ",
"horizon.options.keyboard.horizon": "Horizont",
"horizon.options.keyboard.steam": "Para",
"horizon.options.search": "Nastavenia vyhľadávania...",
@@ -9,12 +9,9 @@
"horizon.options.move_deadzone": "Dödzon för rörelse",
"horizon.options.look_deadzone": "Dödzon för sikt",
"horizon.options.button_glyphs": "Knapp Glyfer",
"horizon.options.menu_navigation": "Menynavigering",
"horizon.options.on_screen_keyboard": "Skärmtangentbord",
"horizon.options.chat_delay": "Chattfördröjning",
"horizon.options.lightbar.health": "Hälsa",
"horizon.options.menu_focus": "Fokusera",
"horizon.options.menu_pointer": "Pekare",
"horizon.options.keyboard.steam": "Ånga",
"horizon.options.unavailable": "Den här funktionen är ännu inte tillgänglig i Horizon.",
"horizon.options.search": "Sökinställningar...",
@@ -10,7 +10,6 @@
"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": "สุขภาพ",
@@ -19,8 +18,6 @@
"horizon.options.glyphs.xbox": "เอกซ์บอกซ์",
"horizon.options.glyphs.playstation": "เพลย์สเตชัน",
"horizon.options.glyphs.nintendo": "นินเทนโด",
"horizon.options.menu_focus": "โฟกัส",
"horizon.options.menu_pointer": "ตัวชี้",
"horizon.options.keyboard.auto": "อัตโนมัติ",
"horizon.options.keyboard.steam": "ไอน้ำ",
"horizon.options.unavailable": "ฟีเจอร์นี้ยังไม่มีให้บริการใน Horizon",
@@ -10,7 +10,6 @@
"horizon.options.move_deadzone": "Hareket Ölü Bölgesi",
"horizon.options.look_deadzone": "Bakış Ölü Bölgesi",
"horizon.options.button_glyphs": "Düğme Simgeleri",
"horizon.options.menu_navigation": "Menü Gezinmesi",
"horizon.options.on_screen_keyboard": "Ekran Klavyesi",
"horizon.options.chat_delay": "Sohbet Gecikmesi",
"horizon.options.lightbar.health": "Can",
@@ -19,8 +18,6 @@
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Menü Odağı",
"horizon.options.menu_pointer": "İşaretçi",
"horizon.options.keyboard.auto": "Otomatik",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
@@ -10,7 +10,6 @@
"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": "Здоров’я",
@@ -19,8 +18,6 @@
"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",
@@ -16,8 +16,6 @@
"horizon.options.glyphs.xbox": "ایکس باکس",
"horizon.options.glyphs.playstation": "پلے اسٹیشن",
"horizon.options.glyphs.nintendo": "ننٹینڈو",
"horizon.options.menu_focus": "فوکس",
"horizon.options.menu_pointer": "پوائنٹر",
"horizon.options.keyboard.auto": "آٹو",
"horizon.options.keyboard.steam": "اسٹیم",
"horizon.options.search": "ترتیبات تلاش کریں...",
@@ -10,14 +10,11 @@
"horizon.options.move_deadzone": "Vùng chết di chuyển",
"horizon.options.look_deadzone": "Vùng chết góc nhìn",
"horizon.options.button_glyphs": "Biểu tượng nút",
"horizon.options.menu_navigation": "Điều hướng menu",
"horizon.options.on_screen_keyboard": "Bàn phím ảo",
"horizon.options.chat_delay": "Trì hoãn trò chuyện",
"horizon.options.lightbar.health": "sức khỏe",
"horizon.options.lightbar.dimension": "Kích thước",
"horizon.options.glyphs.auto": "Tự động",
"horizon.options.menu_focus": "Tập trung",
"horizon.options.menu_pointer": "Con trỏ",
"horizon.options.keyboard.auto": "Tự động",
"horizon.options.keyboard.steam": "hơi nước",
"horizon.options.unavailable": "Tính năng này chưa có ở Horizon.",
@@ -10,7 +10,6 @@
"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": "生命值",
@@ -19,8 +18,6 @@
"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",
+4 -3
View File
@@ -17,9 +17,9 @@ pub use horizon_client_render::{
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_screen, packs, par, particles, poi_sections,
post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk,
section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects,
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
@@ -151,6 +151,7 @@ pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::held;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
+1 -1
View File
@@ -41,7 +41,7 @@ pub fn native_ingredient(
/// stack being matched, `accept` already true (the load-time list named
/// the item). Set `accept` to false to refuse the stack — its components
/// are the usual reason. The hook may be called many times a craft; the
/// answer must be the same for the same stack. API @API:ingredient_test@.
/// answer must be the same for the same stack. API 377.
#[inline(never)]
pub fn native_ingredient_test(
kind: &str,
+92
View File
@@ -941,6 +941,13 @@ pub fn merge_with_neighbours(
}
}
}
/// `ExperienceOrbRenderer.submit`'s billboard before its 0.3 scale, in the
/// order of its four `vertex` calls (bottom left, bottom right, top right, top
/// left). The quad is a whole block tall about the orb's centre and hangs a
/// quarter of it below.
pub const ORB_QUAD: [[f32; 2]; 4] = [[-0.5, -0.25], [0.5, -0.25], [0.5, 0.75], [-0.5, 0.75]];
/// `poseStack.scale(0.3F, 0.3F, 0.3F)`.
pub const ORB_QUAD_SCALE: f32 = 0.3;
/// ExperienceOrb: gravity 0.03, homing towards the player within 8 blocks.
#[derive(Clone, Debug)]
pub struct ExperienceOrb {
@@ -1048,6 +1055,20 @@ impl ExperienceOrb {
_ => 0,
}
}
/// `Entity.getLightProbePosition`: the block an entity is lit from is the
/// one its eye is in. An orb's box is half a block, so its eye is
/// `0.5 * 0.85` above its feet (`EntityDimensions.defaultEyeHeight`).
pub fn light_probe_y(&self) -> f64 {
self.position.y + 0.5 * 0.85
}
/// `ExperienceOrbRenderer.getBlockLightLevel` over `EntityRenderer`'s
/// packing: the level's block light raised by 7 and clamped, the sky
/// light unchanged. `light` is the level's packed light.
pub fn render_light(&self, light: i32) -> u32 {
let block = ((light & 15) + 7).clamp(0, 15);
let sky = light >> 4;
(u32::try_from(sky).unwrap_or(0) * 16) | ((u32::try_from(block).unwrap_or(15) * 16) << 8)
}
pub fn tick(
&mut self,
world: &World,
@@ -3617,4 +3638,75 @@ mod item_destroyed_tests {
);
}
}
#[cfg(test)]
mod render_light_tests {
use super::*;
fn orb(y: f64) -> ExperienceOrb {
ExperienceOrb {
join: Default::default(),
no_gravity: false,
id: 1,
position: Vec3::new(0.5, y, 0.5),
previous: Vec3::new(0.5, y, 0.5),
velocity: Vec3::new(0.0, 0.0, 0.0),
value: 3,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
}
}
/// `ExperienceOrbRenderer.getBlockLightLevel`: the level's block light
/// plus 7, clamped to 15; the sky light is the level's own. The orb used
/// to be drawn at 10 in both nibbles, so an orb in a dark cave was washed
/// out and one in daylight at block light 5 was too dim.
#[test]
fn an_orb_is_seven_brighter_than_the_block_it_sits_in() {
let light = |sky: i32, block: i32| (sky << 4) | block;
// Sky 15, block 0 (daylight): sky stays 15, block goes to 7.
assert_eq!(
orb(64.0).render_light(light(15, 0)),
(15 * 16) | ((7 * 16) << 8)
);
// A dark cave: sky 0, block 0 -> block 7, and no invented sky light.
assert_eq!(orb(20.0).render_light(light(0, 0)), (7 * 16) << 8);
// Torchlight 9 -> 15, the clamp, not 16.
assert_eq!(orb(20.0).render_light(light(0, 9)), (15 * 16) << 8);
assert_eq!(
orb(20.0).render_light(light(15, 15)),
(15 * 16) | ((15 * 16) << 8)
);
}
/// `Entity.getLightProbePosition` is the eye position: an orb's box is
/// half a block, so it reads the light of the cell 0.425 above its feet.
#[test]
fn an_orb_is_lit_from_the_block_its_eye_is_in() {
assert_eq!(orb(64.6).light_probe_y().floor(), 65.0);
assert_eq!(orb(64.6).position.y.floor(), 64.0);
assert!((orb(64.0).light_probe_y() - 64.425).abs() < 1e-9);
}
/// `ExperienceOrbRenderer.submit`'s quad: a block tall about the orb's
/// centre, scaled by 0.3, so it spans -0.075 to +0.225 above the 0.1 the
/// pose is translated by. Horizon drew a symmetric 0.3 square, a quarter
/// of the sprite too low.
#[test]
fn the_orb_sprite_hangs_a_quarter_below_its_centre() {
let off = ORB_QUAD.map(|[x, y]| [x * ORB_QUAD_SCALE, 0.1 + y * ORB_QUAD_SCALE]);
let want = [
[-0.15, 0.1 - 0.075],
[0.15, 0.1 - 0.075],
[0.15, 0.1 + 0.225],
[-0.15, 0.1 + 0.225],
];
for (got, want) in off.into_iter().zip(want) {
assert!((got[0] - want[0]).abs() < 1e-6, "{got:?} vs {want:?}");
assert!((got[1] - want[1]).abs() < 1e-6, "{got:?} vs {want:?}");
}
}
}
crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
+158 -51
View File
@@ -233,6 +233,7 @@ mod native_panel_recipes;
mod native_projectiles;
mod native_quick_move;
mod native_recipes;
mod native_reload_listeners;
mod native_server;
mod native_shaders;
mod native_ticks;
@@ -352,6 +353,7 @@ mod world_loading;
mod zombie_reinforcements;
mod zombie_villagers;
use event_loop::EventLoopCtl;
use horizon_client::held::Source;
use horizon_client::spans::startup;
use horizon_client::{
Result,
@@ -421,6 +423,7 @@ pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeShake};
pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_reload_listeners::NativeReloadListener;
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
@@ -2068,7 +2071,7 @@ impl UiResources {
// The title screen waits for both: the panorama's six faces decode
// beside the atlas.
std::thread::scope(|scope| {
let panorama = scope.spawn(|| {
let panorama = named_scope(scope, "panorama", || {
startup::time("ui.panorama", || {
horizon_client::gfx::panorama::Faces::load(root, defaults)
})
@@ -2420,7 +2423,7 @@ struct Game<'a> {
creative: bool,
/// The game mode; `creative` mirrors `mode == Creative`.
mode: horizon_client::command::args::GameMode,
keys: HashSet<KeyCode>,
keys: horizon_client::held::HeldKeys,
// Physical input is separate from remapped and toggled movement keys.
physical_keys: HashSet<KeyCode>,
physical_buttons: HashSet<MouseButton>,
@@ -2797,6 +2800,9 @@ struct Game<'a> {
/// The camera the last frame drew from (position, yaw, pitch, fov), for the locator bar.
locator_camera: Option<([f64; 3], f32, f32, f32)>,
native_camera_frame: Option<horizon_client::mod_api::NativeCamera>,
/// The yaw and pitch a native mod's camera drew the last frame from (`native_camera_pose`),
/// none when it drew from vanilla's.
posed_rotation: Option<(f32, f32)>,
/// `LocalPlayer.postEffects`: the chains the server has the screen run
/// (`ClientboundPostEffectsPacket`), in order.
post_effects: Vec<String>,
@@ -3928,13 +3934,28 @@ impl Game<'_> {
/// `Player.dropEquipment` for the player who died: unless keep_inventory
/// is on, `destroyVanishingCursedItems`, then `Inventory.dropAll` from
/// `at` (the eyes less 0.3): slots 0..36, armor feet to head (36..39),
/// the off hand (40), each thrown in a random direction.
fn drop_inventory(&mut self, at: [f64; 3]) {
/// the off hand (40), each thrown in a random direction. The mods
/// (`native_loot_drops`, the dying player `who`: uuid and entity id)
/// may add to, change or take out the stacks before they are thrown.
fn drop_inventory(&mut self, at: [f64; 3], who: (u128, i32)) {
if self.ui.rules.bool("keep_inventory") {
return;
}
let stacks = death_drops(&mut self.inventory, &mut self.offhand, &mut self.armor);
let mut stacks = death_drops(&mut self.inventory, &mut self.offhand, &mut self.armor);
self.apply_equipment();
let context = horizon_client::mod_api::NativeLootDrops {
table: "",
dimension: self.dim.id(),
origin: at,
block: None,
entity: Some((who.1 as u32, "minecraft:player")),
player: Some(who.0),
tool: None,
explosion: None,
used: false,
};
self.native_loot_drops(&context, &mut stacks);
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
for stack in stacks {
let r = &mut self.world.random;
let strength = f64::from(r.next_float()) * 0.5;
@@ -7342,6 +7363,8 @@ impl Game<'_> {
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
let held = self.held();
let mut speed = self.items().mining_speed(held, &facts.name);
let hardness = facts.hardness;
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
speed = self.modify_destroy_speed(speed);
speed *= block_break_speed;
let player = self.player.as_ref().ok_or("no player")?;
@@ -7362,11 +7385,10 @@ impl Game<'_> {
{
speed *= self.submerged_mining_speed();
}
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
let progress = if facts.hardness == 0.0 {
let progress = if hardness == 0.0 {
1.0
} else {
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
speed / hardness / if harvest { 30.0 } else { 100.0 }
};
self.swing_arm();
let m = self.mining.as_mut().ok_or("mining")?;
@@ -7832,6 +7854,12 @@ impl Game<'_> {
self.left = false;
self.right = false;
}
/// key.smoothCamera from any device toggles the cinematic camera in play.
fn smooth_camera_action(&mut self) {
if self.screen == Screen::Playing {
self.smooth_camera = !self.smooth_camera;
}
}
fn sync_toggle(&mut self, slot: usize) {
let key = [KeyCode::ShiftLeft, KeyCode::ControlLeft][slot];
if self.toggles.is_down(slot) {
@@ -7895,7 +7923,7 @@ impl Game<'_> {
for mapped in self.settings.keys.translate_all(key) {
match mapped {
Ok(k) => {
self.mapped_key(k, pressed, repeat, key)?;
self.mapped_key(k, pressed, repeat, key, Source::Keyboard)?;
}
Err(button) => {
let action = match (pressed, repeat) {
@@ -7925,13 +7953,15 @@ impl Game<'_> {
/// the event started from (`key` where the caller started from no window
/// key): the screens act in that keyspace (`Screen.keyPressed`), where
/// `key`, the vanilla default the mapping translated to, may itself be
/// another mapping's rebound key.
/// another mapping's rebound key. `source` is the device the event came
/// from: a key stays held while any device holds it.
fn mapped_key(
&mut self,
key: KeyCode,
pressed: bool,
repeat: bool,
raw: KeyCode,
source: Source,
) -> Result<()> {
// Sneak and sprint are ToggleKeyMappings: a press (or a key
// repeat) flips them when toggling; only releases reach them
@@ -7954,11 +7984,14 @@ impl Game<'_> {
if !pressed {
return Ok(());
}
if self.keys.contains(&key) {
self.keys.press(key, source);
}
} else if !pressed {
self.keys.remove(&key);
self.keys.release(&key, source);
return Ok(());
} else {
self.keys.insert(key);
self.keys.press(key, source);
}
// KeyboardHandler.keyPress: a repeat (InputConstants.REPEAT) reaches
// Screen.keyPressed and KeyMapping.click like a press, so holding
@@ -8017,11 +8050,17 @@ impl Game<'_> {
.mouse
.handle_accumulated_movement(time, active, grabbed, look)
{
self.tutorial.mouse(xo, yo);
let zoom = self.zoom_turn_scale();
self.turn_player(xo * zoom, yo * zoom);
self.look_turn(xo, yo);
}
}
/// A look from any device, in `Entity.turn` units: the tutorial's "look
/// around" step hears it and Horizon's zoom slows it, for the mouse and a
/// controller alike.
fn look_turn(&mut self, x: f64, y: f64) {
self.tutorial.mouse(x, y);
let zoom = self.zoom_turn_scale();
self.turn_player(x * zoom, y * zoom);
}
/// `Minecraft.pause`: a single-player game with a pausing screen open.
fn pauses_game(&self) -> bool {
self.has_singleplayer_server()
@@ -10866,7 +10905,11 @@ impl Game<'_> {
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
camera_rotation(self.camera_type, self.yaw, self.pitch)
match self.posed_rotation {
// The frame's camera, already turned round in front view.
Some((yaw, pitch)) => camera_rotation(perspective::CameraType::FirstPerson, yaw, pitch),
None => camera_rotation(self.camera_type, self.yaw, self.pitch),
}
}
/// The entity whose geometry starts at vertex `start` (0: none), for frame
/// generation to follow its own motion. Each run lasts until the next mark.
@@ -11315,20 +11358,26 @@ impl Game<'_> {
let red = ((t.sin() + 1.0) * 0.5 * 255.0) as u32;
let blue = (((t + 4.188_79).sin() + 1.0) * 0.1 * 255.0) as u32;
let color = 0x8000_0000 | blue << 16 | 255 << 8 | red;
// `ExperienceOrbRenderer.getBlockLightLevel` (the block light
// plus 7, clamped) at the block the orb's eye is in.
let light = self.world.light(
o.position.x.floor() as i32,
o.position.y.floor() as i32,
o.light_probe_y().floor() as i32,
o.position.z.floor() as i32,
);
let packed = (u32::from(light >> 4).max(10) * 16)
| ((u32::from(light & 15).max(10) * 16) << 8);
let packed = o.render_light(i32::from(light));
let c = p.as_vec3();
let s = 0.15;
for (k, uv) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.iter()
.zip([[u0, v0 + dv], [u0 + du, v0 + dv], [u0 + du, v0], [u0, v0]])
{
let v = c + right * (k.0 * s) + up * (k.1 * s);
// `ExperienceOrbRenderer.submit`: the quad runs from
// (-0.5, -0.25) to (0.5, 0.75) and is scaled by 0.3, so the
// sprite hangs a quarter of its height below the orb's centre.
let s = horizon_client::entities::ORB_QUAD_SCALE;
for (k, uv) in horizon_client::entities::ORB_QUAD.iter().zip([
[u0, v0 + dv],
[u0 + du, v0 + dv],
[u0 + du, v0],
[u0, v0],
]) {
let v = c + right * (k[0] * s) + up * (k[1] * s);
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
@@ -12302,8 +12351,9 @@ impl Game<'_> {
&self.options.resources,
&pack_files(&self.world_tables),
);
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
// its chunks and players are.
// The mods' own data folders, then TagsUpdatedEvent (SERVER_DATA_LOAD):
// the world's data is in, before its chunks and players are.
self.native_reload_listeners();
self.native_server_data_loaded(false);
self.sync_recipe_content();
self.bosses = Default::default();
@@ -14310,6 +14360,10 @@ impl Game<'_> {
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
),
)?;
renderer.screenshot = screenshot;
renderer.screenshot_result = screenshot_result;
@@ -14347,6 +14401,10 @@ impl Game<'_> {
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
),
)
}
fn apply_settings(&mut self) {
@@ -15672,7 +15730,18 @@ impl Game<'_> {
// Camera.getCameraEntityPartialTicks: the view, the hand and the
// player's own model stay smooth while the level is frozen.
let camera_alpha = self.clock.camera_fraction();
let view = self.camera_view(camera_alpha as f32);
let vanilla = self.camera_view(camera_alpha as f32);
let view = vanilla.map(|view| {
if self.shows_panorama() {
view
} else {
self.native_camera_pose_now(camera_alpha as f32, view)
}
});
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
self.posed_rotation = view
.filter(|posed| Some(*posed) != vanilla)
.map(|(_, yaw, pitch)| (yaw, pitch));
let eye = view.map(|_| {
(
glam::DVec3::from_array(self.view.eye),
@@ -17140,7 +17209,7 @@ impl ApplicationHandler for Game<'_> {
self.native_input_key(&event, modifiers);
}
}
fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
fn device_event(&mut self, event_loop: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) {
// A composited view renders offscreen but its keyboard/mouse owner still
// controls a visible native window. A headless offscreen run never does.
if self.pending_save.is_none()
@@ -17151,6 +17220,9 @@ impl ApplicationHandler for Game<'_> {
&& self.screen == Screen::Playing
&& let DeviceEvent::MouseMotion { delta: (x, y) } = event
{
if x != 0.0 || y != 0.0 {
self.pad_yield_input(event_loop);
}
// MouseHandler.onMove with the pointer grabbed; applied once per frame.
self.note_input(
input_trace::InputOrigin::User,
@@ -17994,7 +18066,7 @@ impl Game<'_> {
&& self.settings.keys.get("key.smoothCamera")
== horizon_client::keybinds::mouse_name(button)
{
self.smooth_camera = !self.smooth_camera;
self.smooth_camera_action();
}
for mapped in self.settings.keys.translate_mouse_all(button) {
match mapped {
@@ -18398,14 +18470,12 @@ impl Game<'_> {
{
return Ok(());
}
// key.smoothCamera toggles the cinematic camera.
if self.screen == Screen::Playing
&& event.state == ElementState::Pressed
if event.state == ElementState::Pressed
&& !event.repeat
&& horizon_client::keybinds::key_name(key)
.is_some_and(|n| self.settings.keys.get("key.smoothCamera") == n)
{
self.smooth_camera = !self.smooth_camera;
self.smooth_camera_action();
}
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
}
@@ -18899,7 +18969,7 @@ impl<'a> Game<'a> {
} else {
horizon_client::command::args::GameMode::Survival
},
keys: HashSet::new(),
keys: Default::default(),
physical_keys: HashSet::new(),
physical_buttons: HashSet::new(),
drag_button: None,
@@ -19113,6 +19183,7 @@ impl<'a> Game<'a> {
waypoints: Default::default(),
locator_camera: None,
native_camera_frame: None,
posed_rotation: None,
post_effects: Vec::new(),
spectated_post_effect: None,
experience_display: Default::default(),
@@ -19747,6 +19818,7 @@ impl Game<'_> {
&overlay,
));
self.adv.reset_tree();
self.native_reload_listeners();
self.native_server_data_loaded(true);
}
@@ -19863,6 +19935,30 @@ fn launch_world(options: &Options) -> Option<&str> {
.flatten()
}
/// A scoped thread with a name, for one step of the resource load.
///
/// Named because the heap census and the startup trace both fall back to the
/// OS thread name: unnamed, every loader's retained memory and every span it
/// ran landed in one `unnamed thread` bucket. That bucket was 330 MiB of a
/// playing game's resident set -- the largest of all of them -- with nothing
/// to say which loader held it, and 13 of the trace's 17 lanes were
/// indistinguishable.
fn named_scope<'scope, T: Send + 'scope>(
scope: &'scope std::thread::Scope<'scope, '_>,
name: &str,
f: impl FnOnce() -> T + Send + 'scope,
) -> std::thread::ScopedJoinHandle<'scope, T> {
std::thread::Builder::new()
// Linux keeps 15 bytes of a thread name: a short prefix keeps them apart
// ("horizon-load-core" and "horizon-load-collision" would both read
// "horizon-load-co").
.name(format!("load-{name}"))
.spawn_scoped(scope, f)
// `Scope::spawn`, which this replaces, panics when the OS refuses a
// thread. Keeping that: a resource load cannot go on without it.
.expect("resource loader thread")
}
/// A step of the resource load: on its own scoped thread, or, when the
/// settings allow a single worker, run where it is joined.
enum Step<'scope, T> {
@@ -19870,13 +19966,15 @@ enum Step<'scope, T> {
Later(Box<dyn FnOnce() -> T + Send + 'scope>),
}
impl<'scope, T: Send + 'scope> Step<'scope, T> {
/// `name` names the thread when one is spawned ([`named_scope`]).
fn spawn(
scope: &'scope std::thread::Scope<'scope, '_>,
parallel: bool,
name: &str,
f: impl FnOnce() -> T + Send + 'scope,
) -> Self {
if parallel {
Self::Thread(scope.spawn(f))
Self::Thread(named_scope(scope, name, f))
} else {
Self::Later(Box::new(f))
}
@@ -19949,7 +20047,7 @@ fn load_core(
let (render, gameplay, visibility, biomes, environment, collision) =
std::thread::scope(|scope| {
let assets = &assets;
let render = Step::spawn(scope, parallel, move || {
let render = Step::spawn(scope, parallel, "render", move || {
// A server reads no block sprites' pixels, which only the
// renderer draws (32 MB).
let parsed_models = parsed_models;
@@ -19976,7 +20074,7 @@ fn load_core(
});
// Nor the face visibility the mesher and the block entity
// renderer cull with (19 MB).
let visibility = Step::spawn(scope, parallel, || {
let visibility = Step::spawn(scope, parallel, "visibility", || {
if headless {
return Ok(horizon_world::block_render::FaceVisibility::empty());
}
@@ -19985,7 +20083,7 @@ fn load_core(
})
.map_err(|e| e.to_string())
});
let biomes = Step::spawn(scope, parallel, || {
let biomes = Step::spawn(scope, parallel, "biomes", || {
let biomes = Arc::new(
startup::time("core.biomes", || Biomes::load(root))
.map_err(|e| e.to_string())?,
@@ -19998,11 +20096,11 @@ fn load_core(
}
Ok::<_, String>(biomes)
});
let environment = Step::spawn(scope, parallel, || {
let environment = Step::spawn(scope, parallel, "environment", || {
startup::time("core.environment", || Environment::load(root))
.map_err(|e| e.to_string())
});
let collision = Step::spawn(scope, parallel, || {
let collision = Step::spawn(scope, parallel, "collision", || {
startup::time("core.collision", || collisions::load(root, registry))
.map_err(|e| e.to_string())
});
@@ -20064,7 +20162,7 @@ fn gameplay_tables(
drop(facts_step);
progress.set(2);
let (light, motion) = std::thread::scope(|scope| {
let light = Step::spawn(scope, parallel, || {
let light = Step::spawn(scope, parallel, "light", || {
startup::time("core.light", || {
LightTables::load(root, assets.registry, &facts)
})
@@ -20139,7 +20237,7 @@ fn load_resources(
};
let (core, loaded_gui) = loop {
let (ui, core) = std::thread::scope(|scope| {
let core = Step::spawn(scope, parallel, || {
let core = Step::spawn(scope, parallel, "core", || {
// A failed pack starts core again from the game's own
// resources, which the world's preload must read: with packs
// it waits for the core that loaded.
@@ -20303,7 +20401,7 @@ fn load_resources(
let prepared_at = Instant::now();
let (game_cpu, renderer_cpu) = if menu {
let (game_cpu, renderer_cpu) = std::thread::scope(|scope| {
let textures = Step::spawn(scope, parallel, || {
let textures = Step::spawn(scope, parallel, "textures", || {
startup::time("handoff.renderer_textures", || {
prepare_renderer_textures(&pack)
})
@@ -20467,11 +20565,13 @@ fn load_world_rest(
}
let (items, behaviours, block_entity_render, animate, mobs, transformers) = if threads > 1 {
std::thread::scope(|scope| {
let items = scope.spawn(|| text(load_items()));
let behaviours = scope.spawn(|| text(load_behaviours()));
let block_entity_render = scope.spawn(|| text(load_block_entity_render()));
let animate = scope.spawn(|| text(load_animate()));
let mobs = scope.spawn(|| text(load_mobs()));
let items = named_scope(scope, "items", || text(load_items()));
let behaviours = named_scope(scope, "behaviours", || text(load_behaviours()));
let block_entity_render = named_scope(scope, "block_entity_render", || {
text(load_block_entity_render())
});
let animate = named_scope(scope, "animate", || text(load_animate()));
let mobs = named_scope(scope, "mobs", || text(load_mobs()));
let transformers = text(load_transformers());
(
items.join().unwrap(),
@@ -20561,6 +20661,7 @@ fn build_renderer(
// offered (`Renderer::new`).
wide_frame: bool,
shader_pack: bool,
filtering: (i32, i32),
) -> Result<Renderer> {
let assets = &pack.render;
let atlas = &gui.atlas;
@@ -20608,6 +20709,8 @@ fn build_renderer(
renderer
.hud
.set_text_shader(&renderer.device, atlas.text_shader.as_deref());
pack.item_renderer
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
@@ -21251,6 +21354,10 @@ impl Loading {
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
),
)
})?;
log::info!(
@@ -21695,7 +21802,7 @@ fn gameplay_has_keyboard(screen: Screen, title: bool, dialog: bool) -> bool {
/// A dialog drops held keys and mouse buttons it does not see (#4974).
fn drop_held_behind_dialog(
dialog: bool,
keys: &mut HashSet<KeyCode>,
keys: &mut horizon_client::held::HeldKeys,
left: &mut bool,
right: &mut bool,
) {
@@ -22385,7 +22492,7 @@ mod tests {
assert!(!gameplay_has_keyboard(Screen::Playing, true, false));
assert!(!gameplay_has_keyboard(Screen::Chat, false, false));
let mut keys = HashSet::from([KeyCode::KeyW]);
let mut keys = horizon_client::held::HeldKeys::from_iter([KeyCode::KeyW]);
let mut left = true;
let mut right = true;
drop_held_behind_dialog(false, &mut keys, &mut left, &mut right);
+10 -3
View File
@@ -1526,11 +1526,18 @@ impl Game<'_> {
/// `key.screenshot` mapping (F2 by default) takes the picture before a
/// screen or a text box sees the key, and the debug modifier suppresses it.
pub(super) fn screenshot_shortcut(&mut self, key: KeyCode, repeat: bool) -> bool {
if repeat || self.debug_keys.modifier_down {
if repeat
|| horizon_client::keybinds::key_name(key)
!= Some(self.settings.keys.get("key.screenshot"))
{
return false;
}
if horizon_client::keybinds::key_name(key) != Some(self.settings.keys.get("key.screenshot"))
{
self.screenshot_action()
}
/// key.screenshot from any device: not while F3 is down, where F3+F2 is a
/// debug key.
pub(super) fn screenshot_action(&mut self) -> bool {
if self.debug_keys.modifier_down {
return false;
}
self.take_screenshot();
@@ -48,8 +48,9 @@ impl Game<'_> {
/// block under a sprinting player's feet streams back from them every
/// tick, for the window's own player and for the other players it sees.
fn sprint_dust(&mut self) {
// Each runner's feet, movement, supporting block and box width.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>, f64)> = Vec::new();
// Each runner's feet, movement, supporting block, box width and index
// in `mobs` (none for the window's own player).
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>, f64, Option<usize>)> = Vec::new();
// Entity.canSpawnSprintParticle.
if let Some(p) = self.player.as_ref()
&& p.sprinting
@@ -62,12 +63,12 @@ impl Game<'_> {
&& p.fluid_height(LAVA) <= 0.0
{
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
runners.push((p.position, p.velocity, support, f64::from(0.6f32)));
runners.push((p.position, p.velocity, support, f64::from(0.6f32), None));
}
// IronGolem.canSpawnSprintParticle replaces the sprinting test: one
// tick in five while it moves at all, in water or not, drawn from the
// golem's own random as its client copy's Entity.baseTick does.
for m in self.mobs.iter_mut() {
for (i, m) in self.mobs.iter_mut().enumerate() {
if &*m.kind.id != "minecraft:iron_golem" {
continue;
}
@@ -75,10 +76,10 @@ impl Game<'_> {
let moving =
movement.x * movement.x + movement.z * movement.z > f64::from(2.5000003E-7_f32);
if moving && m.random.next_int_bound(5).unwrap_or(1) == 0 {
runners.push((m.position, movement, None, f64::from(m.width())));
runners.push((m.position, movement, None, f64::from(m.width()), Some(i)));
}
}
for m in self.mobs.iter() {
for (i, m) in self.mobs.iter().enumerate() {
// Another player's fluids aren't known here: none where they stand.
let feet = [
m.position.x.floor() as i32,
@@ -100,12 +101,18 @@ impl Game<'_> {
.fluid_level
== 0
{
runners.push((m.position, movement, None, f64::from(0.6f32)));
runners.push((m.position, movement, None, f64::from(0.6f32), Some(i)));
}
}
for (position, movement, support, width) in runners {
for (position, movement, support, width, mob) in runners {
self.sprint_particle(position, movement, support, width, |game| {
let r = game.particles.random();
// this.random: a mob's own, so one walking out of sight leaves
// the particles' random alone. The window's player has none of
// its own here; the particles' random stands in for it.
let r = match mob {
Some(i) => &mut game.mobs.get_mut(i)?.random,
None => game.particles.random(),
};
Some((r.next_double(), r.next_double()))
});
}
@@ -257,6 +257,22 @@ impl Game<'_> {
self.harness_owner(|g| g.break_block(pos))?;
}
"portal" => self.harness_portal()?,
"destroy_progress" => {
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
let bits = self.harness_owner(|g| {
if let Some(player) = g.player.as_mut() {
player.ground.on_ground = true;
}
Ok(g.destroy_progress(pos).to_bits())
})?;
let record = json!({
"cmd": "#destroy_progress",
"messages": [],
"results": [[true, bits]],
});
out.push_str(&record.to_string());
out.push('\n');
}
"falling" => {
// Physical falling blocks are not command-selector targets
// yet. Inspect the actual level rather than that unrelated gap.
+8 -1
View File
@@ -661,7 +661,13 @@ impl Game<'_> {
// The system's auto-repeat while the key is held.
"repeat" => {
self.physical_keys.insert(physical);
self.mapped_key(key, true, true, key)?;
self.mapped_key(
key,
true,
true,
key,
horizon_client::held::Source::Keyboard,
)?;
}
"tap" => {
self.control_key_down(key, physical)?;
@@ -1303,6 +1309,7 @@ impl Game<'_> {
"culled_sections": r.culled_sections(),
"gpu_culling": r.gpu_culling(),
"gpu_frame": r.gpu_frame(),
"gpu_fading": r.gpu_fading(),
"uploaded_bytes": r.uploaded_bytes,
"resident_sections": r.resident_sections(),
"arena_bytes": arena_bytes,
+27 -1
View File
@@ -311,7 +311,7 @@ impl Game<'_> {
swimming: p.swimming,
look: view_vector(self.yaw, self.pitch),
main_hand: self.held().map(|s| s.item.clone()),
off_hand: None,
off_hand: self.offhand.as_ref().map(|s| s.item.clone()),
head: self.armor[0].as_ref().map(|s| s.item.clone()),
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
invisible: self.potions.effects.get("invisibility").is_some(),
@@ -2018,6 +2018,13 @@ impl Game<'_> {
let ctx = self.who_context(Some(Who::Mob(source)), None);
self.post_game_event(event, at, ctx);
}
// Armadillo.rollUp/rollOut: `gameEvent(ENTITY_ACTION)`.
MobEvent::GameEvent {
event, at, source, ..
} if event == "minecraft:entity_action" => {
let ctx = self.who_context(Some(Who::Mob(source)), None);
self.post_game_event(event, at, ctx);
}
// The sculk layer tracks steps and bee/bat flaps itself
// (`entity_motion_events`, `tick_mob_sculk_events`). Birds
// above use their movement's flyDist/nextFlap threshold.
@@ -7414,6 +7421,7 @@ pub(super) fn melee(kind: horizon_client::mobs::DamageKind) -> bool {
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, caused: bool) -> &'static str {
use horizon_client::mobs::DamageKind as K;
match kind {
K::Named(named) => named.name,
K::PlayerAttack => "minecraft:player_attack",
K::MaceSmash => "minecraft:mace_smash",
K::Spear => "minecraft:spear",
@@ -7786,6 +7794,24 @@ mod breeding_tests {
mod damage_type_tests {
use super::*;
#[test]
fn named_damage_keeps_its_registry_id_with_or_without_an_attacker() {
let named = horizon_client::mobs::NamedDamage::new(
"example:fan_fire",
horizon_client::mobs::NamedDamage::IS_FIRE,
);
for attacker in [None, Some(Who::Mob(7)), Some(Who::Player)] {
assert_eq!(
source_damage_type(Damage {
kind: DamageKind::Named(named),
attacker,
from: None,
}),
"example:fan_fire"
);
}
}
/// `DamageSources.explosion`: a blast with a causing entity is
/// `player_explosion` whoever caused it, so an exploding creeper's too.
#[test]
@@ -1989,6 +1989,15 @@ impl Game<'_> {
self.store_property("max-players", max_players.to_string());
}
/// `DedicatedServer.setAcceptsTransfers`: whether a transfer's handshake is accepted from
/// now on, and `accepts-transfers` in server.properties.
fn set_accepts_transfers(&mut self, accept: bool) {
if let Some(lan) = &self.lan {
lan.hub.lan.shared.set_accepts_transfers(accept);
}
self.store_property("accepts-transfers", accept.to_string());
}
/// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in
/// server.properties.
pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
@@ -416,6 +416,33 @@ impl Game<'_> {
.is_some_and(|host| host.hub.lan.shared.enable_status())
});
}
"serversettings/accept_transfers" => {
return json!({
"value": self
.lan
.as_ref()
.is_some_and(|host| host.hub.lan.shared.accepts_transfers())
});
}
"serversettings/accept_transfers/set" => {
let Some(accept) = body["value"].as_bool() else {
return json!({ "error": "accepts transfers must be a boolean" });
};
let current = self
.lan
.as_ref()
.is_some_and(|host| host.hub.lan.shared.accepts_transfers());
rpc(format!(
"Update accepts transfers from {current} to {accept}"
));
self.set_accepts_transfers(accept);
return json!({
"value": self
.lan
.as_ref()
.is_some_and(|host| host.hub.lan.shared.accepts_transfers())
});
}
"serversettings/player_idle_timeout" => {
return json!({ "value": state.lists.lock().unwrap().player_idle_timeout });
}
@@ -34,14 +34,13 @@ fn block_break_speed_factor(value: Option<f64>) -> f32 {
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
/// and mining fatigue.
pub(super) fn modify_destroy_speed(&self, mut speed: f32) -> f32 {
pub(super) fn modify_destroy_speed(&mut self, mut speed: f32) -> f32 {
if speed > 1.0 {
let held = self.inventory[self.selected].as_ref();
speed += self
.res
.stations()
.enchantments
.attribute_add(held, "minecraft:mining_efficiency") as f32;
.player
.as_mut()
.and_then(|player| player.attribute("minecraft:mining_efficiency").ok())
.unwrap_or(0.0) as f32;
}
speed * self.potions.effects.mining_speed_factor()
}
@@ -1042,15 +1041,25 @@ impl Game<'_> {
})
})
}
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
/// Enchantment attribute modifiers of active equipment, including held Efficiency.
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
/// so the same enchantment on two pieces stacks instead of replacing itself.
pub(super) fn apply_enchantment_attributes(&mut self) {
let mut wanted: Vec<(String, String, f64, String)> = Vec::new();
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
for (stack, slot) in self
.armor
.iter()
.zip(["head", "chest", "legs", "feet"])
.chain([
(&self.inventory[self.selected], "mainhand"),
(&self.offhand, "offhand"),
])
{
let Some(stack) = stack else { continue };
for (attr, id, amount, op) in self.stations().enchantments.attributes(stack) {
wanted.push((attr, format!("{id}/{slot}"), amount, op));
for (attr, id, amount, op, group) in self.stations().enchantments.modifier_rows(stack) {
if super::equipment_modifier_applies(&group, slot) {
wanted.push((attr, format!("{id}/{slot}"), amount, op));
}
}
}
let Some(p) = self.player.as_mut() else {
+1 -1
View File
@@ -57,7 +57,7 @@ pub(super) struct PackStack {
impl PackStack {
/// The enabled packs in `enabled` (the selected) order, later overriding
/// earlier: `PackRepository.openAllSelected` keeps the selected order.
fn enabled_packs(&self) -> impl Iterator<Item = &DataPack> {
pub(super) fn enabled_packs(&self) -> impl Iterator<Item = &DataPack> {
self.enabled
.iter()
.filter_map(|id| self.packs.iter().find(|p| &p.id == id))
+3 -2
View File
@@ -383,6 +383,8 @@ impl Game<'_> {
}
let held = self.held();
let mut speed = self.items().mining_speed(held, &facts.name);
let hardness = facts.hardness;
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
speed = self.modify_destroy_speed(speed);
speed *= block_break_speed;
if let Some(eye) = self.eye_position()
@@ -402,8 +404,7 @@ impl Game<'_> {
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
speed /= 5.0;
}
let harvest = !facts.requires_tool || self.items().correct_for_drops(held, &facts.name);
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
speed / hardness / if harvest { 30.0 } else { 100.0 }
}
/// A key for the main-hand stack (`destroyingItem`): its item and components.
pub(super) fn main_hand_key(&self) -> String {
+25 -4
View File
@@ -166,7 +166,7 @@ pub(super) struct GuestSwap {
/// The host's command permission, swapped for the guest's for its turn.
permission: u8,
xp: Experience,
host_keys: std::collections::HashSet<KeyCode>,
host_keys: horizon_client::held::HeldKeys,
screen: Screen,
host_sign: Option<super::sign_edit::SignEditor>,
host_book: Option<super::books::BookScreen>,
@@ -1199,6 +1199,7 @@ impl Game<'_> {
.facts()
.get(self.world.block(pos.0, pos.1, pos.2))
.name;
let block = name.to_string();
let (menu, slots) = if &**name == "minecraft:ender_chest" {
// EnderChestBlock.useWithoutItem: the player's own inventory.
("minecraft:generic_9x3", Slots::Ender)
@@ -1353,8 +1354,28 @@ impl Game<'_> {
// lid (a vehicle keeps its own `Container`, an ender chest has none).
let counts_openers = matches!(slots, Slots::Block(_) | Slots::Double(_, _));
let hidden = self.native_hidden_slots(pos, items.len());
// A mod menu type's block tells the client where it is, with what its
// mod writes (`openMenu` with an extra-data writer); a stock menu
// sends nothing more.
let extra = match slots {
Slots::Block(k) if self.mod_menu_at(k).is_some() => {
let mut data = None;
self.native_menu_extra_data(uuid, menu, &block, [k.0, k.1, k.2], &mut data);
let mut data = data.unwrap_or_default();
if data.len() > horizon_protocol::menu_extra::MAX_DATA {
log::warn!(
"Horizon: {block}'s menu extra data ({} bytes) is past {} bytes; sent without it",
data.len(),
horizon_protocol::menu_extra::MAX_DATA
);
data.clear();
}
Some(data)
}
_ => None,
};
host.hub
.open_container(conn, menu, title, pos, slots, items);
.open_container(conn, menu, title, pos, slots, items, extra);
if !hidden.is_empty() {
host.hub.hide_menu_slots(conn, hidden);
}
@@ -1418,7 +1439,7 @@ impl Game<'_> {
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
host.hub
.open_container(conn, menu, title, pos, slots, items);
.open_container(conn, menu, title, pos, slots, items, None);
true
}
@@ -3932,7 +3953,7 @@ impl Game<'_> {
// the `deaths` statistic and the two timers it resets.
self.as_guest(host, conn, |game| {
if let Some(eyes) = drops {
game.drop_inventory(eyes);
game.drop_inventory(eyes, (uuid, id));
}
game.score_player_died();
game.award_custom_stat("deaths", 1);
@@ -112,6 +112,10 @@ fn labelled(key: &str, value: Text) -> Text {
}
impl WorldOptions {
/// No button keeps the focus a click gave it.
pub(super) fn clear_click_focus(&mut self) {
self.focused_button = None;
}
/// Vanilla 26.3 WorldOptionsScreen.generalOptions: guests see difficulty,
/// game rules and restrictions; game modes, commands and LAN are host-only.
fn visible_buttons(&self, w: f32) -> Vec<(Button, f32, f32, f32)> {
@@ -10,13 +10,13 @@ impl Game<'_> {
/// layer, as `WorldDrawn::block_sprites` cuts them), where it lies on the side, how its texture runs across it,
/// its tint source and whether it is cut out, translucent or animated; its particle sprite; whether it is a plain
/// cube. None for a block the game does not know. The model is the state's first random variant. API
/// @API:native_block_sprites@.
/// 380.
pub(super) fn native_block_sprites(&self, blocks: &[&str]) -> Vec<Option<BlockSprites>> {
let lookup = self.native_sprite_lookup();
blocks.iter().map(|block| lookup.block(block)).collect()
}
/// For a client mod: each texture's sprite by name (`minecraft:block/stone`, `block/stone`), none for one no
/// model uses. API @API:native_block_sprites@.
/// model uses. API 380.
pub(super) fn native_texture_sprites(&self, textures: &[&str]) -> Vec<Option<SpriteRef>> {
let lookup = self.native_sprite_lookup();
textures
@@ -25,7 +25,7 @@ impl Game<'_> {
.collect()
}
/// For a client mod: changes each time the resources load (a resource pack turned on or off can move every
/// sprite): ask `native_block_sprites` again then. API @API:native_block_sprites@.
/// sprite): ask `native_block_sprites` again then. API 380.
pub(super) fn native_block_sprites_version(&self) -> u64 {
self.pack.context.render.generation
}
@@ -12,7 +12,7 @@ const MAX_TAGS: usize = 1024;
impl Game<'_> {
/// The entity's data as `/data get entity` shows it
/// (`Entity.saveWithoutId`): its compound without `id`. An entity with no
/// UUID yet gets the one it will be saved with. API @API:entity_data@.
/// UUID yet gets the one it will be saved with. API 386.
pub(super) fn native_entity_nbt(&mut self, entity: EntityRef) -> Option<Compound> {
let t = self.native_entity_target(entity)?;
match self.entity_nbt(t)? {
@@ -28,7 +28,7 @@ impl Game<'_> {
/// changed. False for a player (vanilla refuses them too), a kind Horizon
/// can't reload yet (only mobs, dropped items, armor stands and item
/// frames), data nested too deep, or data that changes nothing. API
/// @API:entity_data@.
/// 386.
pub(super) fn native_entity_merge_nbt(&mut self, entity: EntityRef, nbt: Compound) -> bool {
let Some(t) = self.native_entity_target(entity) else {
return false;
@@ -44,12 +44,12 @@ impl Game<'_> {
merged != old && self.apply_entity_nbt(t, merged).is_ok()
}
/// The entity's tags (`Entity.getTags`), as `/tag <entity> list` shows
/// them. API @API:entity_data@.
/// them. API 386.
pub(super) fn native_entity_tags(&self, entity: EntityRef) -> Option<Vec<String>> {
Some(self.tags_of(self.native_entity_target(entity)?))
}
/// Adds tag `tag` (`Entity.addTag`): false when it has it already, holds
/// 1024, its kind can't store tags yet, or the handle is stale. API @API:entity_data@.
/// 1024, its kind can't store tags yet, or the handle is stale. API 386.
pub(super) fn native_entity_add_tag(&mut self, entity: EntityRef, tag: &str) -> bool {
let Some(t) = self.native_entity_target(entity) else {
return false;
@@ -64,7 +64,7 @@ impl Game<'_> {
self.set_entity_tags(t, &tags)
}
/// Removes tag `tag` (`Entity.removeTag`): false when it didn't have it.
/// API @API:entity_data@.
/// API 386.
pub(super) fn native_entity_remove_tag(&mut self, entity: EntityRef, tag: &str) -> bool {
let Some(t) = self.native_entity_target(entity) else {
return false;
@@ -75,7 +75,7 @@ impl Game<'_> {
tags.len() != before && self.set_entity_tags(t, &tags)
}
/// The entity's name as plain text (`Entity.getName`): its custom name,
/// a player's name, else its type's name. API @API:entity_data@.
/// a player's name, else its type's name. API 386.
pub(super) fn native_entity_name(&self, entity: EntityRef) -> Option<String> {
Some(self.target_name(self.native_entity_target(entity)?).plain())
}
@@ -30,7 +30,7 @@ impl Game<'_> {
/// The entity's velocity in blocks a tick (`Entity.getDeltaMovement`). A
/// player's is the move its client last reported (`getKnownMovement`); an
/// entity Horizon doesn't simulate (a display, a painting) has none, so
/// zero. API @API:entity_motion@.
/// zero. API 379.
pub(super) fn native_entity_velocity(&self, entity: EntityRef) -> Option<[f64; 3]> {
let t = self.native_entity_target(entity)?;
if let Some(g) = self.native_target_guest(t) {
@@ -47,7 +47,7 @@ impl Game<'_> {
/// Sets the entity's velocity (`Entity.setDeltaMovement`); clients see it
/// as they see the entity move. False for a player (its client moves
/// it), an entity Horizon doesn't simulate, or a value that isn't finite.
/// API @API:entity_motion@.
/// API 379.
pub(super) fn native_entity_set_velocity(
&mut self,
entity: EntityRef,
@@ -67,7 +67,7 @@ impl Game<'_> {
true
}
/// Whether the entity stands on a block (`Entity.onGround`). API
/// @API:entity_motion@.
/// 379.
pub(super) fn native_entity_on_ground(&self, entity: EntityRef) -> Option<bool> {
let t = self.native_entity_target(entity)?;
if let Some(g) = self.native_target_guest(t) {
@@ -80,7 +80,7 @@ impl Game<'_> {
}
/// The entity's box (`Entity.getBoundingBox`): its lowest and highest
/// corner. None for the kinds whose box Horizon doesn't keep (displays,
/// markers, End crystals, the dragon). API @API:entity_motion@.
/// markers, End crystals, the dragon). API 379.
pub(super) fn native_entity_bounding_box(&self, entity: EntityRef) -> Option<[[f64; 3]; 2]> {
let t = self.native_entity_target(entity)?;
if let Some(g) = self.native_target_guest(t) {
@@ -97,25 +97,25 @@ impl Game<'_> {
Some([lo, hi])
}
/// The height of the entity's eyes above its feet (`Entity.getEyeHeight`).
/// API @API:entity_motion@.
/// API 379.
pub(super) fn native_entity_eye_height(&self, entity: EntityRef) -> Option<f64> {
Some(self.target_eye_height(self.native_entity_target(entity)?))
}
/// Where the entity's eyes are (`Entity.getEyePosition`). API
/// @API:entity_motion@.
/// 379.
pub(super) fn native_entity_eye_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
let [x, y, z] = self.native_entity_position(entity)?;
Some([x, y + self.native_entity_eye_height(entity)?, z])
}
/// The unit vector the entity looks along (`Entity.getViewVector`, from
/// its pitch and yaw). API @API:entity_motion@.
/// its pitch and yaw). API 379.
pub(super) fn native_entity_view_vector(&self, entity: EntityRef) -> Option<[f64; 3]> {
let [yaw, pitch] = self.native_entity_rotation(entity)?;
let v = horizon_world::view_direction::view_vector(pitch, yaw);
Some([v.x, v.y, v.z])
}
/// The square of the distance from the entity's feet to `pos`
/// (`Entity.distanceToSqr`). API @API:entity_motion@.
/// (`Entity.distanceToSqr`). API 379.
pub(super) fn native_entity_distance_to_sqr(
&self,
entity: EntityRef,
@@ -37,14 +37,14 @@ impl Game<'_> {
/// The entity `id` (the id events carry) as a handle for the `entity_*`
/// methods: None on a client, with no world open, or for no such entity
/// in the level in place or among the server's players. API
/// @API:entity_handles@.
/// 373.
pub(super) fn native_entity(&self, id: u32) -> Option<EntityRef> {
let entity = EntityRef::new(self.native_lifecycle.epoch()?, id);
self.native_entity_target(entity).map(|_| entity)
}
/// The level `dimension` (`minecraft:the_nether`) as a handle: None on a
/// client, with no world open, or for a dimension the server hasn't
/// loaded. API @API:entity_handles@.
/// loaded. API 373.
pub(super) fn native_level(&self, dimension: &str) -> Option<LevelRef> {
if self.remote.is_some() {
return None;
@@ -56,29 +56,29 @@ impl Game<'_> {
Some(LevelRef::new(epoch, dim.id()))
}
/// The entity's position (`Entity.position`): its feet, the middle of
/// its box for a painting or an item frame. API @API:entity_handles@.
/// its box for a painting or an item frame. API 373.
pub(super) fn native_entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
self.target_position(self.native_entity_target(entity)?)
}
/// The block the entity's position is in (`Entity.blockPosition`). API
/// @API:entity_handles@.
/// 373.
pub(super) fn native_entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
self.native_entity_position(entity)
.map(|p| p.map(|v| v.floor() as i32))
}
/// The entity's yaw and pitch in degrees (`Entity.getYRot`, `getXRot`); 0
/// for the kinds that don't turn. API @API:entity_handles@.
/// for the kinds that don't turn. API 373.
pub(super) fn native_entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
Some(self.target_rotation(self.native_entity_target(entity)?))
}
/// The entity's type (`Entity.getType`): `minecraft:zombie`. API
/// @API:entity_handles@.
/// 373.
pub(super) fn native_entity_kind(&self, entity: EntityRef) -> Option<String> {
Some(self.target_type(self.native_entity_target(entity)?))
}
/// The entity's UUID (`Entity.getUUID`), the one it is saved with; an
/// entity that has none yet gets it now, as a new vanilla entity does.
/// API @API:entity_handles@.
/// API 373.
pub(super) fn native_entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
let words = match self.native_entity_target(entity)? {
Target::Player => return self.command_guest(self.lan_acting?).map(|g| g.uuid),
@@ -87,7 +87,7 @@ impl Game<'_> {
};
Some(uuid_from_words(words))
}
/// The level the entity is in (`Entity.level`). API @API:entity_handles@.
/// The level the entity is in (`Entity.level`). API 373.
pub(super) fn native_entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
let dim: Dim = self.target_level(self.native_entity_target(entity)?);
Some(LevelRef::new(entity.epoch(), dim.id()))
+305 -26
View File
@@ -6,6 +6,91 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 399 adds a player's chat message before it is sent (`native_player_chat`).
//!
//! API 398 adds the player's network entity id on `NativeServerPlayer` (`entity_id`).
//!
//! API 397 adds mod config files, loaded and reloaded (`native_register_configs`, `native_config_loading`,
//! `native_config_reloading`, `native_config_unloading`; `native_configs.rs`).
//!
//! API 396 adds a slime or magma cube splitting on death, cancellable (`native_mob_split`).
//!
//! API 395 adds two animals breeding and a baby about to spawn, cancellable (`native_baby_entity_spawn`).
//!
//! API 394 adds experience points a server player is about to gain, changeable or cancelled (`native_player_xp_change`).
//!
//! API 393 adds the problem that stops a player sleeping in a bed, changeable (`native_can_player_sleep`).
//!
//! API 392 adds a living entity about to die, cancellable (`native_living_death`).
//!
//! API 391 adds a mob about to convert into another, cancellable (`native_living_conversion_pre`).
//!
//! API 390 adds a mob converted into another (`native_living_conversion_post`).
//!
//! API 389 adds A village siege found a spawn position, cancellable (`native_village_siege`).
//!
//! API 388 adds Possible natural spawn entries, mutable and cancellable (`native_potential_spawns`).
//!
//! API 387 adds A player tunes a note block (`native_note_block_change`).
//!
//! API 386 adds an entity's data (read and merge, as `/data` does), tags and name through an `EntityRef`.
//!
//! API 385 adds per-stack conditional item model properties (`native_item_model_property`).
//!
//! API 384 adds a host-aware player-weapon damage hook (`native_living_incoming_damage_v2`), leaving v1 compatible.
//!
//! API 383 adds living_knockback_v2: raw knockback with the attacker's entity id and held items, including armor-stand sweeps.
//!
//! API 382 adds living entity handles (`LivingRef`, `native_living`) and a living entity's equipment and hand items, read and set.
//!
//! API 381 adds a level's blocks and time through a `LevelRef`: block state, block, property, whether a chunk is loaded, height, game time and day time.
//!
//! API 380 adds block textures for client mods: `native_block_sprites` and `native_texture_sprites` say where blocks' and textures' sprites are in the block sprite arrays, and `WorldDrawn::block_sprites` hands a mod the arrays.
//!
//! API 379 adds an entity's velocity (and setting it), ground, box, eye height and position, view vector and distance through an `EntityRef`.
//!
//! API 378 adds validated category declarations for custom recipe types (`native_recipe_book_categories`, `native_recipe_book_category`); display and unlock integration follows separately.
//!
//! API 377 adds a mod's own ingredient kind matching a concrete stack
//! (`native_ingredient_test`, which the ingredient's test calls with the kind,
//! its JSON, the stack and the verdict — the load-time list already named
//! the item).
//!
//! API 376 adds The ammunition a bow or crossbow shooter finds (`native_living_get_projectile`).
//!
//! API 375 adds A player is about to nock a bow (`native_arrow_nock`).
//!
//! API 374 adds Explosion velocity replacement (`native_explosion_knockback`).
//!
//! API 373 adds entity and level handles (`EntityRef`, `LevelRef`, `native_entity`, `native_level`) and an entity's position, block position, rotation, type, UUID and level.
//!
//! API 372 adds a name tag hook (native_render_name_tag).
//!
//! API 371 adds `WorldDrawn::motion`: which mob, player or dropped item each run of the frame's entity quads is.
//!
//! API 370 adds a mod item holding items (`native_item_container`), and the item container helpers in
//! `native_item_container.rs`: `native_item_container_slots`, `native_item_container_insert`, `native_item_container_extract`.
//!
//! API 369 adds an additive first-person hand and item pose (`native_render_hand_pose`).
//!
//! API 368 adds tooltip box colours (`native_tooltip_color`).
//!
//! API 367 adds the screen_opening screen lifecycle observation (`native_screen_opening`) at most once per frame.
//!
//! API 366 adds the screen_init_post screen lifecycle observation (`native_screen_init_post`) at most once per frame.
//!
//! API 365 adds the screen_closing screen lifecycle observation (`native_screen_closing`) at most once per frame.
//!
//! API 364 adds the render hook point `native_hand_drawn(hand)`: the first-person hand is drawn (`HandDrawn`).
//!
//! API 363 adds reading the world camera and block collision boxes (`native_camera`, `native_block_collision`).
//!
//! API 362 adds a per-tick player FOV modifier hook (`native_compute_fov_modifier`).
//!
//! API 361 adds a cancellable item frame item render hook (`native_render_item_in_frame`).
//!
//! API 360 adds Local player movement input before application (`native_movement_input`).
//!
//! API 359 adds a living entity jumping (`native_living_jump`).
//!
//! API 358 adds a living entity healing, with a changeable amount and cancellation (`native_living_heal`).
@@ -880,6 +965,7 @@ use super::{
native_configs::{NativeConfig, NativeConfigFile},
native_mob_draw::{NativeMobRender, NativePlayerRender},
native_panel_recipes::RecipePage,
native_reload_listeners::NativeReloadListener,
};
use horizon_assets::item_model::StackView;
use horizon_client::mod_api::{
@@ -1048,7 +1134,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 = 359;
pub(crate) const NATIVE_HOOKS_API: u32 = 399;
impl GameLevel<'_> {
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. 232.
@@ -1278,7 +1364,7 @@ impl GameLevel<'_> {
/// client prediction. `context` gives its dimension, position, instrument
/// and old note; `new_note` starts as vanilla's cycled note. Set it to 0
/// through 24; an out-of-range value keeps vanilla's. Cancel to leave the
/// note unchanged and prevent playback. API @API:native_note_block_change@.
/// note unchanged and prevent playback. API 387.
#[inline(always)]
pub(super) fn native_note_block_change(
&self,
@@ -1759,6 +1845,27 @@ pub struct NativeShake {
pub offset: [f32; 3],
}
/// A mod's camera (`native_camera_pose`): each value that isn't finite keeps vanilla's from `view`.
fn posed_camera(
view: (glam::DVec3, f32, f32),
position: [f64; 3],
yaw: f32,
pitch: f32,
) -> (glam::DVec3, f32, f32) {
let position = std::array::from_fn(|i| {
if position[i].is_finite() {
position[i]
} else {
view.0[i]
}
});
(
glam::DVec3::from_array(position),
if yaw.is_finite() { yaw } else { view.1 },
if pitch.is_finite() { pitch } else { view.2 },
)
}
/// The largest turn a shake gives, in degrees, on each axis.
const SHAKE_MAX_DEGREES: f32 = 10.0;
/// The largest offset a shake gives, in blocks, along each axis.
@@ -1826,17 +1933,28 @@ fn shake_pose(shake: NativeShake) -> glam::Mat4 {
}
/// The mod screen open on this client: the block it is for, the key its
/// slots are under, the window id of its menu, and the menu's data slots as
/// the server sent them.
/// slots are under, the window id of its menu, the menu's data slots as
/// the server sent them, and what the server sent before the menu opened
/// (`horizon:menu_extra`), when it did.
pub(super) struct OpenNativeScreen {
key: Pos,
window: i32,
block: String,
screen: NativeScreen,
data: Vec<i32>,
extra: Option<horizon_protocol::menu_extra::Message>,
}
impl OpenNativeScreen {
/// The block the menu is of: where the server said, else the clicked
/// block its slots are under (a server without `horizon:menu_extra`).
fn pos(&self) -> Option<[i32; 3]> {
match &self.extra {
Some(extra) => Some(extra.pos),
None if self.key.0 != i32::MIN => Some([self.key.0, self.key.1, self.key.2]),
None => None,
}
}
/// Data slot `id` of window `window`'s menu: false for another window's
/// (an old or replaced one), which this screen leaves alone.
fn set_data(&mut self, window: i32, id: i16, value: i16) -> bool {
@@ -2194,6 +2312,32 @@ impl Game<'_> {
pub(super) fn native_server_data_loaded(&mut self, reload: bool) {
mod_api::dispatch_server_data_loaded(self, mod_api::mods(), reload);
}
/// The folders of JSON files native mods read from the data
/// (`AddServerReloadListenersEvent`): push yours, each a listener id and a
/// folder under `data/<namespace>/`. Asked at the start of every data load
/// of a server: a world opening, `/reload`. A folder that leaves
/// `data/<namespace>/` is skipped. API @API:server_reload_listeners@.
pub(super) fn native_add_server_reload_listeners(
&mut self,
listeners: &mut Vec<NativeReloadListener>,
) {
mod_api::dispatch_add_server_reload_listeners(self, mod_api::mods(), listeners);
}
/// A listener's files were read (`SimpleJsonResourceReloadListener`'s
/// `apply`): `files` holds `<namespace>:<path>` (the file's path under
/// the folder, without `.json`) and its JSON, sorted by id, from the
/// resource root (vanilla's and content mods' data) and the world's enabled
/// data packs; for each id the top pack's file, and a file that isn't JSON
/// is logged and left out. Every mod hears every listener, so check
/// `listener.id`. Fired on each data load before `native_server_data_loaded`.
/// Server-side. API @API:server_reload_listeners@.
pub(super) fn native_server_reload_listener_apply(
&mut self,
listener: &NativeReloadListener,
files: &[(String, serde_json::Value)],
) {
mod_api::dispatch_server_reload_listener_apply(self, mod_api::mods(), listener, files);
}
/// The server is assembling its data tables from the world's data packs
/// (`ReloadableServerResources`): a world is opening, or `/reload` was
/// run, and the recipes, tags, loot and advancements are built after
@@ -2253,7 +2397,7 @@ impl Game<'_> {
/// (`Common` and `Server`) and by a server before each world it opens
/// (`Server`); a game reads only its own kinds, so push them all every
/// time. A file outside `config/`, or one already registered, is skipped.
/// API @API:mod_configs@.
/// API 397.
pub(super) fn native_register_configs(&mut self, configs: &mut Vec<NativeConfig>) {
mod_api::dispatch_register_configs(self, mod_api::mods(), configs);
}
@@ -2261,7 +2405,7 @@ impl Game<'_> {
/// says whose, which file and its text, which the mod parses (TOML by
/// convention). Every mod hears every config, so check `mod_id`. Fired
/// where the config's kind loads (`native_register_configs`), a `Server`
/// one before `native_server_about_to_start`. API @API:mod_configs@.
/// one before `native_server_about_to_start`. API 397.
pub(super) fn native_config_loading(&mut self, config: &NativeConfigFile) {
mod_api::dispatch_config_loading(self, mod_api::mods(), config);
}
@@ -2269,14 +2413,14 @@ impl Game<'_> {
/// its new text. The game looks at the files' modification times once a
/// second, on the window and on the server's tick; a file saved with the
/// same text, removed or unreadable fires nothing and keeps its last
/// text. API @API:mod_configs@.
/// text. API 397.
pub(super) fn native_config_reloading(&mut self, config: &NativeConfigFile) {
mod_api::dispatch_config_reloading(self, mod_api::mods(), config);
}
/// A `Server` config is dropped as its server stops
/// (`ModConfigEvent.Unloading`), after `native_server_stopping`; the next
/// world reads it again. `Client` and `Common` configs stay until the
/// game quits. API @API:mod_configs@.
/// game quits. API 397.
pub(super) fn native_config_unloading(&mut self, config: &NativeConfigFile) {
mod_api::dispatch_config_unloading(self, mod_api::mods(), config);
}
@@ -2500,7 +2644,7 @@ impl Game<'_> {
mod_api::dispatch_commands(self, mod_api::mods(), commands);
}
/// Player FOV scale, after vanilla effects and before zoom and easing.
/// Invalid values or values outside 0.01..=10 revert to base. API @API:compute_fov_modifier@.
/// Invalid values or values outside 0.01..=10 revert to base. API 362.
pub(super) fn native_compute_fov_modifier(
&self,
player: &horizon_client::camera::FovPlayer,
@@ -2562,7 +2706,7 @@ impl Game<'_> {
pub(super) fn native_camera_shake(&self, partial: f32, shake: &mut NativeShake) {
mod_api::dispatch_camera_shake(self, mod_api::mods(), partial, shake);
}
/// Name tag visibility and optional replacement text. API @API:render_name_tag@.
/// Name tag visibility and optional replacement text. API 372.
pub(super) fn native_render_name_tag(
&self,
entity_id: u32,
@@ -2608,7 +2752,7 @@ impl Game<'_> {
/// Additional first-person hand and item pose, including empty arms and maps.
/// Add offsets in blocks and pitch, yaw and roll in degrees; multiply scale.
/// API @API:render_hand_pose@.
/// API 369.
pub(super) fn native_render_hand_pose(
&self,
hand: horizon_client::first_person::Hand,
@@ -2660,6 +2804,21 @@ impl Game<'_> {
);
}
/// The camera this frame is drawn from, `partial` ticks past the last tick, after vanilla
/// placed it (`Camera.setup`): its position, and its yaw and pitch in degrees. Set them to
/// draw the world from anywhere: a cinematic or replay camera. Only the picture, the sound's
/// listener and the billboards facing the camera follow it; the player, where they aim, the
/// first-person hand and which chunks load stay the player's. Values that aren't finite keep
/// vanilla's. Not called on the title screen's panorama. API @API:camera_pose@.
pub(super) fn native_camera_pose(
&self,
partial: f32,
position: &mut [f64; 3],
yaw: &mut f32,
pitch: &mut f32,
) {
mod_api::dispatch_camera_pose(self, mod_api::mods(), partial, position, yaw, pitch);
}
/// Final scaled third-person camera distance, before visual-shape collision clipping.
/// `entity` includes its living scale/distance and optional mount scale/distance.
/// Only finite requests in 0..=1024 blocks replace the vanilla distance.
@@ -2923,7 +3082,7 @@ impl Game<'_> {
/// `native_item_container_insert`, `native_item_container_extract`, in
/// `native_item_container.rs`). The contents are kept in the stack's
/// `minecraft:container`, as a shulker box item keeps them.
/// API @API:item_container@.
/// API 370.
pub(super) fn native_item_container(&self, item: &str, slots: &mut Option<u32>) {
mod_api::dispatch_item_container(self, mod_api::mods(), item, slots);
}
@@ -4269,7 +4428,7 @@ impl Game<'_> {
/// without a message) to refuse. The spawn point is set before it fires,
/// as in vanilla, only when vanilla found no problem. After it,
/// `native_player_bed_enter` still fires and may cancel. Server-side.
/// API @API:can_player_sleep@.
/// API 393.
pub(super) fn native_can_player_sleep(
&mut self,
player: u128,
@@ -4417,7 +4576,7 @@ impl Game<'_> {
/// an advancement reward gives nothing for a negative amount. Experience
/// bottles and furnaces make orbs, so they come through the orb path.
/// `native_player_exp_change` is separate and still fires first for an orb
/// pickup. Server-side. API @API:player_xp_change@.
/// pickup. Server-side. API 394.
pub(super) fn native_player_xp_change(
&mut self,
player: u128,
@@ -4638,6 +4797,23 @@ impl Game<'_> {
) {
mod_api::dispatch_inventory_block_start(self, mod_api::mods(), player, name, pos, cancel);
}
/// Block `block` at `pos` opens its mod menu type `menu` for server
/// player `player`: set `data` to hand the menu's screen bytes of your
/// own (a machine's tier, mode or recipes), up to 32 KiB, which the
/// client reads with `native_screen_extra_data` while the screen is open
/// (NeoForge's `openMenu` with an extra-data writer). The first mod to
/// set it wins, so answer only for your blocks. The player's state is not
/// swapped in. @API:menu_extra_data@.
pub(super) fn native_menu_extra_data(
&mut self,
player: u128,
menu: &str,
block: &str,
pos: [i32; 3],
data: &mut Option<Vec<u8>>,
) {
mod_api::dispatch_menu_extra_data(self, mod_api::mods(), player, menu, block, pos, data);
}
/// A sculk catalyst at `pos` has begun blooming after taking experience.
/// The catalyst block state has changed. Server-side.
@@ -4951,6 +5127,27 @@ impl Game<'_> {
) {
mod_api::dispatch_trade_with_villager(self, mod_api::mods(), merchant, kind, offer);
}
/// A trade of a villager or a wandering trader was made and its `offer`
/// (the live one in the trading session, not a copy) is in the state the
/// trade left it in: `uses` counted. A mod may change it (Bukkit's
/// `PlayerTradeEvent#getTrade` is mutable): `uses`, `max_uses`, `demand`,
/// `special_price_diff`, `price_multiplier`, `xp`. The change stands: the
/// next click and the offers sent to the player see it. Once per trade (a
/// shift-click that trades several times asks for each trade only after
/// the click, so the stock it ran down in that click is not given back
/// before the click ends), before the trade's statistic and
/// `trade_with_villager`, which hear the offer as the trade left it.
/// `merchant` is the trader's entity id, `kind` its type. Not asked for a
/// trade window without a trader. Server-side. API
/// @API:merchant_offer_traded@.
pub(super) fn native_merchant_offer_traded(
&mut self,
merchant: u32,
kind: &str,
offer: &mut MerchantOffer,
) {
mod_api::dispatch_merchant_offer_traded(self, mod_api::mods(), merchant, kind, offer);
}
/// A server player's shield was disabled by a blocked melee hit. The
/// shield cooldown is set immediately after this call. Helpers looking up
/// the guest through `self.lan` may find nothing during packet handling.
@@ -5216,7 +5413,7 @@ impl Game<'_> {
/// A player is about to nock `bow` with `hand` (`BowItem.use`, NeoForge's
/// `ArrowNockEvent`): `has_ammo` is whether `getProjectile` found ammunition
/// or the player is in creative. Set `cancel` to fail the use.
/// @API:arrow_nock@.
/// 375.
pub(super) fn native_arrow_nock(
&mut self,
player: u128,
@@ -5243,7 +5440,7 @@ impl Game<'_> {
/// mob (`entity`, `kind`): a skeleton or illusioner's shot, or the crossbow
/// load of a pillager or piglin. `projectile` is the vanilla
/// ammunition; replace it to choose another. A replaced stack is not taken
/// from the inventory. @API:living_get_projectile@.
/// from the inventory. 376.
pub(super) fn native_living_get_projectile(
&mut self,
player: Option<u128>,
@@ -5499,7 +5696,7 @@ impl Game<'_> {
/// size of the children (half of it) and `count` how many vanilla drew
/// (2 to 4). A mod may change `count` (0 spawns none, at most 64) or set
/// `cancel` to spawn no children. Only for a parent above size 1; the
/// split's random draw is made first. On the server. API @API:mob_split@.
/// split's random draw is made first. On the server. API 396.
pub(super) fn native_mob_split(
&mut self,
parent: u32,
@@ -5531,7 +5728,7 @@ impl Game<'_> {
/// statistic, no advancement and no heart particles, as NeoForge
/// returns before `finalizeSpawnChildFromBreeding`. Not fired for
/// turtles, frogs and sniffers (an egg, no baby), villagers or
/// armadillos' own paths. On the server. API @API:baby_entity_spawn@.
/// armadillos' own paths. On the server. API 395.
pub(super) fn native_baby_entity_spawn(
&mut self,
parent: u32,
@@ -5567,7 +5764,7 @@ impl Game<'_> {
/// Possible natural spawns at a server position (NeoForge's
/// `LevelEvent.PotentialSpawns`, `NaturalSpawner.mobsAt`). Mods may add or
/// remove entries, or cancel to make the list empty.
/// @API:native_potential_spawns@.
/// 388.
pub(super) fn native_potential_spawns(
&self,
dimension: &str,
@@ -5624,7 +5821,7 @@ impl Game<'_> {
/// A village siege found a spawn position on the server. Set `canceled`
/// to stop the siege. There is one siege per dimension.
/// @API:native_village_siege@.
/// 389.
pub(super) fn native_village_siege(
&mut self,
dimension: &str,
@@ -5715,6 +5912,39 @@ impl Game<'_> {
mod_api::dispatch_broadcast_message(self, mod_api::mods(), line, cancel);
}
/// NeoForge's `ClientChatReceivedEvent`: a chat line from the server is
/// about to show, once Chat Delay lets it through. `system` is a system
/// message (`ClientChatReceivedEvent.System`), shown in the action bar
/// when `overlay` is set; otherwise a player or disguised message in its
/// chat type `chat_type` (`getBoundChatType`, its registry name; `None`
/// for a system message or an inline chat type). `sender` is the player's
/// UUID, 0 (`Util.NIL_UUID`) for system and disguised messages. `message`
/// starts as the decorated line; replace it and the chat shows the new
/// one, while the narrator still says the original. Set `cancel` and it
/// doesn't show: a delayed message lets the next one through. Lines the
/// client adds itself and validation errors are not told. Client-side.
/// API @API:client_chat_received@.
pub(super) fn native_client_chat_received(
&mut self,
system: bool,
overlay: bool,
sender: u128,
chat_type: Option<&str>,
message: &mut Text,
cancel: &mut bool,
) {
mod_api::dispatch_client_chat_received(
self,
mod_api::mods(),
system,
overlay,
sender,
chat_type,
message,
cancel,
);
}
/// The dedicated server's whitelist changed to `enabled`. 305.
pub(super) fn native_whitelist_state_update(&mut self, enabled: bool) {
mod_api::dispatch_whitelist_state_update(self, mod_api::mods(), enabled);
@@ -5758,7 +5988,7 @@ impl Game<'_> {
/// mob that dealt it, 0 for none or a player. Set `cancel` to keep it alive:
/// Horizon decides death by health, so it stays at 1 health. The hook
/// fires once per death attempt. Armor stands, boats and other non-mob
/// entities are not told. API @API:living_death@.
/// entities are not told. API 392.
pub(super) fn native_living_death(
&mut self,
entity: u32,
@@ -5785,7 +6015,7 @@ impl Game<'_> {
/// lightning. `outcome` is the new type. Set `cancel` to keep the mob as
/// it is; a drowning zombie starts its timers again. Not told: a
/// mooshroom a player shears, a zombie villager's cure, a villager turned
/// by a zombie. API @API:living_conversion_pre@.
/// by a zombie. API 391.
pub(super) fn native_living_conversion_pre(
&mut self,
entity: u32,
@@ -5807,7 +6037,7 @@ impl Game<'_> {
/// `LivingConversionEvent.Post`): `entity` is gone, `converted` took its
/// place. Fires with `native_entity_transform`, after the new mob joined
/// the level, for the conversions `native_living_conversion_pre` tells.
/// API @API:living_conversion_post@.
/// API 390.
pub(super) fn native_living_conversion_post(
&mut self,
entity: u32,
@@ -6531,7 +6761,7 @@ impl Game<'_> {
/// what is added to the entity's velocity; a player's client is sent the
/// same. Wind bursts too, except dragons and end crystals. End crystals
/// are also omitted from ordinary blasts: Horizon removes them without a
/// velocity to change. Server-side. @API:explosion_knockback@.
/// velocity to change. Server-side. 374.
pub(super) fn native_explosion_knockback(
&mut self,
dimension: &str,
@@ -7435,7 +7665,7 @@ impl Game<'_> {
/// DamageSource position. The acting player's network id is `causing`
/// (also the direct entity). Weapon enchantments include their levels.
/// Runs after the victim's invulnerability check (an invulnerable or dead victim fires nothing) and before v1; cancellation refuses
/// delivery. Other damage routes still only fire v1. API @API:living_incoming_damage_v2@.
/// delivery. Other damage routes still only fire v1. API 384.
pub(super) fn native_living_incoming_damage_v2(
&mut self,
entity: i32,
@@ -7518,7 +7748,7 @@ impl Game<'_> {
/// include the guest whose action runs. Includes mob melee and armor-stand sweeps, not
/// direct velocity pushes (explosions, Punch arrows, hoglin throws). Non-finite changes
/// are restored before resistance. Server-side, without self: damage borrows the victim.
/// API @API:living_knockback_v2@.
/// API 383.
pub(super) fn native_living_knockback_v2(
entity: i32,
kind: &str,
@@ -8719,6 +8949,19 @@ impl Game<'_> {
pub(super) fn native_screen_title(&self) -> Option<String> {
self.remote_menu_title().map(|t| t.plain())
}
/// The block of the mod screen that is open (`native_screen`), as the
/// server named it when the block opened its menu type; None when no mod
/// screen is open. @API:menu_extra_data@.
pub(super) fn native_screen_pos(&self) -> Option<[i32; 3]> {
self.shown_native_screen()?.pos()
}
/// What the block's mod wrote for the mod screen that is open
/// (`native_menu_extra_data` on the server), empty when it wrote nothing;
/// None when no mod screen is open. @API:menu_extra_data@.
pub(super) fn native_screen_extra_data(&self) -> Option<&[u8]> {
let screen = self.shown_native_screen()?;
Some(screen.extra.as_ref().map_or(&[], |e| e.data.as_slice()))
}
/// A data file of the game's resources, content mods' included
/// (`data/mymod/machines.json`), when there is one: what a mod's content
/// says, read at runtime. Only relative paths of plain names. API 20.
@@ -9436,12 +9679,14 @@ impl Game<'_> {
}
/// A menu opened on this client with its slots under `key`, on block
/// `target`: a mod block's is laid out by its mod (`native_screen`).
/// `extra` is what the server sent for this window first.
pub(super) fn open_native_screen(
&mut self,
key: Pos,
target: Option<Pos>,
window: i32,
menu: &str,
extra: Option<horizon_protocol::menu_extra::Message>,
) {
self.mod_screen = None;
let Some(t) = target else {
@@ -9475,6 +9720,7 @@ impl Game<'_> {
block,
screen,
data: Vec::new(),
extra,
});
}
}
@@ -9861,6 +10107,18 @@ impl Game<'_> {
shake_pose(bounded_shake(shake, self.screen_effect_scale()))
}
/// The camera native mods draw this frame from (`native_camera_pose`), starting from
/// `view`, vanilla's position, yaw and pitch.
pub(super) fn native_camera_pose_now(
&self,
partial: f32,
view: (glam::DVec3, f32, f32),
) -> (glam::DVec3, f32, f32) {
let (mut position, mut yaw, mut pitch) = (view.0.to_array(), view.1, view.2);
self.native_camera_pose(partial, &mut position, &mut yaw, &mut pitch);
posed_camera(view, position, yaw, pitch)
}
/// What native mods hand shader packs in this frame (`native_shader_params`),
/// with values that aren't finite as 0.
pub(super) fn native_shader_params_now(&self, partial: f32) -> [[f32; 4]; 8] {
@@ -10235,6 +10493,26 @@ mod tests {
assert_eq!(super::chars_to_type("", |_| false), None);
}
/// A mod's camera replaces vanilla's value by value; one that isn't finite keeps vanilla's.
#[test]
fn a_mod_camera_replaces_each_finite_value() {
use super::posed_camera;
let vanilla = (glam::DVec3::new(1.0, 2.0, 3.0), 10.0, 20.0);
assert_eq!(
posed_camera(vanilla, [-40.5, 90.0, 1e7], 270.0, -35.0),
(glam::DVec3::new(-40.5, 90.0, 1e7), 270.0, -35.0)
);
assert_eq!(
posed_camera(
vanilla,
[f64::NAN, 5.0, f64::INFINITY],
f32::NAN,
f32::NEG_INFINITY
),
(glam::DVec3::new(1.0, 5.0, 3.0), 10.0, 20.0)
);
}
/// A shake is scaled by Distortion Effects, bounded on each axis, and a
/// value that isn't finite is none.
#[test]
@@ -10415,6 +10693,7 @@ mod tests {
block: "gtmod:machine".into(),
screen: super::NativeScreen::default(),
data: Vec::new(),
extra: None,
};
assert!(s.set_data(4, 1, 7));
assert!(!s.set_data(3, 1, 99));
@@ -37,7 +37,7 @@ impl Game<'_> {
/// The block state at `pos` (`Level.getBlockState`) as `/setblock` takes
/// it: `minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]`.
/// None for a chunk that isn't loaded or a `y` outside the world. API
/// @API:level_reads@.
/// 381.
pub(super) fn native_level_block_state(
&self,
level: LevelRef,
@@ -47,14 +47,14 @@ impl Game<'_> {
.map(|state| self.state_string(state))
}
/// The block at `pos` without its properties: `minecraft:oak_stairs`.
/// None as for `block_state`. API @API:level_reads@.
/// None as for `block_state`. API 381.
pub(super) fn native_level_block(&self, level: LevelRef, pos: [i32; 3]) -> Option<String> {
self.native_level_state(level, pos)
.map(|state| self.res.facts().get(state).name.to_string())
}
/// Property `key` of the block state at `pos` (`BlockState.getValue`):
/// `east` for `facing`. None for a block without it, or as for
/// `block_state`. API @API:level_reads@.
/// `block_state`. API 381.
pub(super) fn native_level_block_property(
&self,
level: LevelRef,
@@ -72,7 +72,7 @@ impl Game<'_> {
}
/// Whether `pos` is inside the world's height and its chunk is loaded
/// (`Level.isLoaded`, which is false outside `isInValidBounds`): a block
/// read or write there finds the world. API @API:level_reads@.
/// read or write there finds the world. API 381.
pub(super) fn native_level_is_loaded(&self, level: LevelRef, pos: [i32; 3]) -> bool {
self.native_level_world(level).is_some_and(|w| {
(w.bottom_y()..w.top_y()).contains(&pos[1])
@@ -80,19 +80,19 @@ impl Game<'_> {
})
}
/// The lowest block `y` and the height of the level
/// (`LevelHeightAccessor.getMinY`, `getHeight`). API @API:level_reads@.
/// (`LevelHeightAccessor.getMinY`, `getHeight`). API 381.
pub(super) fn native_level_height(&self, level: LevelRef) -> Option<(i32, i32)> {
self.native_level_world(level)
.map(|w| (w.shape.min_y, w.shape.height))
}
/// The ticks the server has run (`Level.getGameTime`), shared by every
/// level as in vanilla. API @API:level_reads@.
/// level as in vanilla. API 381.
pub(super) fn native_level_game_time(&self, level: LevelRef) -> Option<i64> {
self.native_level_world(level)?;
Some(self.world.game_time)
}
/// The time of day in ticks (`Level.getOverworldClockTime`), shared by
/// every level; it keeps counting past 24000. API @API:level_reads@.
/// every level; it keeps counting past 24000. API 381.
pub(super) fn native_level_day_time(&self, level: LevelRef) -> Option<i64> {
self.native_level_world(level)?;
Some(self.world.day_time)
@@ -62,14 +62,14 @@ impl Game<'_> {
}
/// The entity as a living entity (vanilla's `LivingEntity`: a mob, a
/// player, an armor stand or the dragon), or None for another kind or a
/// stale handle. API @API:living_refs@.
/// stale handle. API 382.
pub(super) fn native_living(&self, entity: EntityRef) -> Option<LivingRef> {
let living = LivingRef::new(entity);
self.native_living_target(living).map(|_| living)
}
/// The stack in equipment slot `slot` (`LivingEntity.getItemBySlot`):
/// None when it is empty, the entity has no such slot (a player has no
/// body or saddle slot), or the handle is stale. API @API:living_refs@.
/// body or saddle slot), or the handle is stale. API 382.
pub(super) fn native_living_equipment(
&self,
living: LivingRef,
@@ -101,7 +101,7 @@ impl Game<'_> {
}
}
/// The stack in hand `hand` (`LivingEntity.getItemInHand`). API
/// @API:living_refs@.
/// 382.
pub(super) fn native_living_hand_item(
&self,
living: LivingRef,
@@ -118,7 +118,7 @@ impl Game<'_> {
/// when the entity has no such slot: a player has no body or saddle
/// slot, the dragon none. A mob has every slot, head and body apart
/// (`EntityEquipment`), as `/item` does; Horizon keeps no mob saddle here
/// yet. API @API:living_refs@.
/// yet. API 382.
pub(super) fn native_living_set_equipment(
&mut self,
living: LivingRef,
@@ -162,4 +162,29 @@ impl Game<'_> {
}));
Ok(())
}
/// A mod's chat line or command (`/` first), sent as `send_chat` sends
/// it (ClientPacketListener.sendChat / sendCommand), to any server: no
/// chat history, and no client chat event (vanilla's `ClientChatEvent`
/// comes only from ChatScreen). An error when the player isn't in a
/// server's play state or the Chat option blocks the message. API
/// @API:send_chat@.
pub(super) fn native_send_chat(&mut self, message: &str) -> Result<(), String> {
let Some(remote) = self.remote.as_ref() else {
return Err("not connected to a server".into());
};
// 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());
}
let message = message.trim();
if message.is_empty() {
return Err("the message is empty".into());
}
if !self.chat_abilities().allows(message) {
return Err("the Chat option blocks this message".into());
}
self.send_chat(message);
Ok(())
}
}
@@ -0,0 +1,244 @@
//! Native mods' own data folders, read on each data load of a server
//! (NeoForge's `AddServerReloadListenersEvent`, docs/native-mods.md). A mod
//! names a folder under `data/<namespace>/` (`native_add_server_reload_listeners`);
//! when a world opens and on `/reload`, the folder's `.json` files of the
//! resource root (vanilla's and content mods') and of the world's enabled data
//! packs are read as vanilla's `SimpleJsonResourceReloadListener.prepare` reads
//! its folder, and handed to the mod (`native_server_reload_listener_apply`).
use super::{Game, functions};
use horizon_client::datapack::DataPack;
use std::collections::BTreeMap;
use std::path::{Component, Path};
/// A folder of JSON files a mod reads from the data packs, as vanilla's
/// `SimpleJsonResourceReloadListener` over `FileToIdConverter.json(folder)`.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct NativeReloadListener {
/// `mymod:ore_veins`: which of the mod's listeners the files are for.
pub id: String,
/// The folder under `data/<namespace>/`: `ore_veins`, `mymod/ore_veins`.
pub folder: String,
}
impl NativeReloadListener {
/// The `.json` files of `data/<namespace>/<folder>/`, for listener `id`.
pub fn json(id: &str, folder: &str) -> Self {
Self {
id: id.to_owned(),
folder: folder.to_owned(),
}
}
}
/// Whether `folder` stays below `data/<namespace>/`.
fn folder_ok(folder: &str) -> bool {
let path = Path::new(folder);
!folder.contains('\\')
&& path.components().next().is_some()
&& path.components().all(|c| matches!(c, Component::Normal(_)))
}
/// `FileToIdConverter.fileToId`: `data/<ns>/<folder>/<path>.json` is
/// `<ns>:<path>`; None for a file of another folder or extension.
fn file_id(file: &str, folder: &str) -> Option<String> {
let (ns, rest) = file.strip_prefix("data/")?.split_once('/')?;
let path = rest
.strip_prefix(folder)?
.strip_prefix('/')?
.strip_suffix(".json")?;
(!path.is_empty()).then(|| format!("{ns}:{path}"))
}
/// The folder's files over `packs`, the bottom one first: each id's file is
/// the top pack's (`ResourceManager.listResources`), parsed; one that isn't
/// JSON is logged and skipped, as `prepare` does. Sorted by id.
fn read_folder(
packs: &[&DataPack],
listener: &str,
folder: &str,
) -> Vec<(String, serde_json::Value)> {
let mut top: BTreeMap<String, (&DataPack, String)> = BTreeMap::new();
for pack in packs {
for file in pack.list_folder(folder) {
if let Some(id) = file_id(&file, folder) {
top.insert(id, (pack, file));
}
}
}
let mut files = Vec::with_capacity(top.len());
for (id, (pack, file)) in top {
let Some(bytes) = pack.read(&file) else {
continue;
};
match serde_json::from_slice(&bytes) {
Ok(json) => files.push((id, json)),
Err(e) => log::error!(
"Horizon: couldn't parse data file '{id}' from '{file}' in pack {} for {listener}: {e}",
pack.id
),
}
}
files
}
impl Game<'_> {
/// A server's data load (a world opening, `/reload`): every listener's
/// files go to its mod, before `native_server_data_loaded`.
pub(super) fn native_reload_listeners(&mut self) {
let mut listeners = Vec::new();
self.native_add_server_reload_listeners(&mut listeners);
if listeners.is_empty() {
return;
}
let stack = functions::pack_stack(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
);
let packs: Vec<&DataPack> = stack.builtin.iter().chain(stack.enabled_packs()).collect();
for listener in &listeners {
if !folder_ok(&listener.folder) {
log::warn!(
"Horizon: reload listener {} folder {:?} is not a folder under data/<namespace>/",
listener.id,
listener.folder
);
continue;
}
let files = read_folder(&packs, &listener.id, &listener.folder);
self.native_server_reload_listener_apply(listener, &files);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use std::path::PathBuf;
struct Folder(PathBuf);
impl Folder {
fn new(name: &str) -> Self {
let dir = std::env::temp_dir().join(format!(
"horizon-reload-listeners-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
Self(dir)
}
/// A folder pack with these files.
fn pack(&self, name: &str, files: &[(&str, &str)]) -> DataPack {
let root = self.0.join(name);
for (path, text) in files {
let path = root.join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
DataPack::builtin(&root)
}
}
impl Drop for Folder {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn the_top_pack_wins_each_id_and_ids_drop_the_folder_and_extension() {
let dir = Folder::new("top");
let root = dir.pack(
"root",
&[
("data/gt/ore_veins/copper.json", r#"{"weight": 1}"#),
("data/gt/ore_veins/deep/tin.json", r#"{"weight": 2}"#),
("data/other/ore_veins/zinc.json", r#"{"weight": 3}"#),
// Another folder, and a name that only starts like it.
("data/gt/recipe/plate.json", "{}"),
("data/gt/ore_veins_old/lead.json", "{}"),
],
);
let world = dir.pack(
"world",
&[
("data/gt/ore_veins/copper.json", r#"{"weight": 10}"#),
("data/gt/ore_veins/notes.txt", "not json, not read"),
],
);
let files = read_folder(&[&root, &world], "gt:veins", "ore_veins");
assert_eq!(
files,
[
("gt:copper".to_owned(), json!({"weight": 10})),
("gt:deep/tin".to_owned(), json!({"weight": 2})),
("other:zinc".to_owned(), json!({"weight": 3})),
]
);
}
#[test]
fn a_broken_top_file_is_skipped_without_falling_back() {
let dir = Folder::new("broken");
let root = dir.pack(
"root",
&[
("data/gt/machines/press.json", r#"{"tier": 1}"#),
("data/gt/machines/mixer.json", r#"{"tier": 2}"#),
],
);
let world = dir.pack("world", &[("data/gt/machines/press.json", "{ tier: ")]);
let files = read_folder(&[&root, &world], "gt:machines", "machines");
assert_eq!(files, [("gt:mixer".to_owned(), json!({"tier": 2}))]);
}
#[test]
fn a_nested_folder_reads_only_below_it() {
let dir = Folder::new("nested");
let pack = dir.pack(
"root",
&[
("data/gt/gt/veins/a.json", "1"),
("data/gt/gt/other/b.json", "2"),
],
);
let files = read_folder(&[&pack], "gt:veins", "gt/veins");
assert_eq!(files, [("gt:a".to_owned(), json!(1))]);
}
#[test]
fn a_zipped_pack_reads_only_the_folder() {
let dir = Folder::new("zip");
let packs = dir.0.join("datapacks");
fs::create_dir_all(&packs).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(packs.join("veins.zip")).unwrap());
for (name, text) in [
("pack.mcmeta", r#"{"pack":{"pack_format":48}}"#),
("data/gt/ore_veins/iron.json", r#"{"y": -16}"#),
("data/gt/ore_veins_old/lead.json", "{}"),
("data/gt/recipe/plate.json", "{}"),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
std::io::Write::write_all(&mut zip, text.as_bytes()).unwrap();
}
zip.finish().unwrap();
let found = horizon_client::datapack::discover_folder(&packs);
let refs: Vec<&DataPack> = found.iter().collect();
let files = read_folder(&refs, "gt:veins", "ore_veins");
assert_eq!(files, [("gt:iron".to_owned(), json!({"y": -16}))]);
}
#[test]
fn a_folder_outside_the_namespace_is_refused() {
assert!(folder_ok("ore_veins"));
assert!(folder_ok("gt/ore_veins"));
for bad in ["", "..", "../recipe", "gt/../../x", "/abs", "a\\b"] {
assert!(!folder_ok(bad), "{bad:?}");
}
}
}
@@ -220,6 +220,7 @@ impl Game<'_> {
yaw: g.yaw,
pitch: g.pitch,
entity_id: g.entity_id,
address: g.address.ip().to_string(),
})
.collect()
}
@@ -455,6 +456,17 @@ impl Game<'_> {
yaw: p.yaw,
pitch: p.pitch,
entity_id: self.native_acting_entity() as i32,
address: self
.lan
.as_ref()
.and_then(|host| {
host.hub
.guests
.iter()
.find(|g| Some(g.uuid) == self.lan_acting_uuid)
})
.map(|g| g.address.ip().to_string())
.unwrap_or_default(),
})
}
/// Runs `line` (no slash) as the server's console does: permission
@@ -993,6 +1005,7 @@ impl Game<'_> {
yaw: g.yaw,
pitch: g.pitch,
entity_id: g.entity_id,
address: g.address.ip().to_string(),
};
let mut ran = false;
self.native_perform_command(&player, line, &mut ran);
+4
View File
@@ -357,6 +357,10 @@ impl Game<'_> {
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
super::shader_packs::picked_pack_draws_flat(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
),
)
})
.transpose()
+149 -31
View File
@@ -5,10 +5,10 @@
//! injects a virtual pad on top of the real one.
use super::event_loop::EventLoopCtl;
use super::{Game, Screen};
use horizon_client::held::Source;
use horizon_client::{
Result,
gamepad::{self, Button, Edges, GlyphStyle, HealthWatch, PadState, Rumble},
keybinds::{KeyBinds, key_code, mouse_button},
gamepad::{self, Button, Edges, GlyphStyle, HealthWatch, PadState, Route, Rumble},
keymapping::SNEAK,
};
use serde_json::{Value, json};
@@ -38,6 +38,11 @@ pub(super) struct Pads {
/// Buttons a `pad ... tap` releases at the next poll.
taps: Vec<Button>,
edges: Edges,
activity: gamepad::Activity,
context: Option<Context>,
blocked_sticks: [bool; 2],
/// Releases use the action that received the press, even after a rebind.
actions: [Vec<&'static str>; 16],
/// The game tick each button went down in, and releases held back until
/// a tick has passed: vanilla samples keys once per tick, so a click
/// shorter than that (a quick stick click for sprint) would be lost.
@@ -59,8 +64,19 @@ pub(super) struct Pads {
health: HealthWatch,
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct Context {
screen: u64,
focus_navigation: bool,
focused: bool,
osk: bool,
enabled: bool,
}
#[cfg(feature = "controllers")]
mod hardware;
#[cfg(test)]
mod parity_tests;
pub(super) use hardware::{PadId, PadRouter};
/// What a controller did since the last frame.
@@ -166,17 +182,32 @@ impl Pads {
}
impl Game<'_> {
fn pad_context(&self) -> Context {
Context {
screen: self.key_screen(),
focus_navigation: self.pad_navigation_mode(),
focused: self.focused,
osk: self.pad.menu.osk.is_some(),
enabled: self.settings.controller.enabled,
}
}
/// Once per loop iteration: read the pads and act on button changes.
pub(super) fn poll_pads(&mut self, event_loop: &dyn EventLoopCtl) {
let settings = &self.settings.controller;
if !settings.enabled {
self.pad_output_off();
if self.pad.state != PadState::default() {
self.pad.state = PadState::default();
for (button, down) in self.pad.edges.release_all() {
self.pad_edge(event_loop, button, down);
}
self.pad.virtual_input = self.pad.injected != PadState::default();
let context = self.pad_context();
if let Some(old) = self.pad.context.filter(|old| *old != context) {
if old.screen != context.screen {
self.pad_remember_focus(old.screen);
}
self.pad_cancel_inputs(event_loop);
}
self.pad.context = Some(context);
if !self.settings.controller.enabled {
self.pad_output_off();
self.pad_cancel_inputs(event_loop);
self.pad.state = PadState::default();
self.pad.in_use = false;
// The keyboard's menu focus follows its widget all the same.
self.pad_menu_frame(event_loop);
return;
@@ -198,14 +229,28 @@ impl Game<'_> {
);
}
self.ui.options.pad_style = self.settings.controller.glyphs.resolve(self.pad.style);
let state = hardware.merge(&self.pad.injected);
self.pad.virtual_input = self.pad.injected != PadState::default();
let mut state = hardware.merge(&self.pad.injected);
let activity = self.pad.activity.observe(&state, &self.settings.controller);
for (i, (stick, deadzone)) in [
(&mut state.left, self.settings.controller.move_deadzone),
(&mut state.right, self.settings.controller.look_deadzone),
]
.into_iter()
.enumerate()
{
if gamepad::radial(*stick, deadzone) == [0.0; 2] {
self.pad.blocked_sticks[i] = false;
}
if self.pad.blocked_sticks[i] || !self.pad_focused() {
*stick = [0.0; 2];
}
}
for button in std::mem::take(&mut self.pad.taps) {
self.pad.injected.set(button, false);
}
let threshold = self.settings.controller.trigger_threshold;
let edges = self.pad.edges.update(&state, threshold);
if state.active(&self.settings.controller) {
if activity && self.pad_focused() {
self.last_input = Instant::now();
self.pad.in_use = true;
}
@@ -231,6 +276,15 @@ impl Game<'_> {
}
for (button, down) in edges {
self.pad_edge(event_loop, button, down);
let context = self.pad_context();
if let Some(old) = self.pad.context.filter(|old| *old != context) {
if old.screen != context.screen {
self.pad_remember_focus(old.screen);
}
self.pad_cancel_inputs(event_loop);
self.pad.context = Some(context);
break;
}
}
self.pad_menu_frame(event_loop);
}
@@ -251,6 +305,41 @@ impl Game<'_> {
self.pad_button_logged(event_loop, button, down);
}
/// End held actions and captures before another context receives input.
fn pad_cancel_inputs(&mut self, event_loop: &dyn EventLoopCtl) {
let held_sticks = [
gamepad::radial(self.pad.state.left, self.settings.controller.move_deadzone)
!= [0.0; 2],
gamepad::radial(self.pad.state.right, self.settings.controller.look_deadzone)
!= [0.0; 2],
];
for (blocked, held) in self.pad.blocked_sticks.iter_mut().zip(held_sticks) {
*blocked |= held;
}
self.pad.state.left = [0.0; 2];
self.pad.state.right = [0.0; 2];
self.pad.late_releases.clear();
for (button, down) in self.pad.edges.cancel() {
self.pad_button_logged(event_loop, button, down);
}
// A short tap may already be up in Edges but still await its simulation tick.
for button in Button::ALL {
for action in std::mem::take(&mut self.pad.actions[button as usize]) {
if let Err(e) = self.pad_action(action, false) {
log::warn!("Horizon: releasing controller action {action}: {e}");
}
}
}
self.pad_cancel_menu(event_loop);
}
pub(super) fn pad_yield_input(&mut self, event_loop: &dyn EventLoopCtl) {
if self.pad.in_use {
self.pad.in_use = false;
self.pad_cancel_inputs(event_loop);
}
}
fn pad_button_logged(&mut self, event_loop: &dyn EventLoopCtl, button: Button, down: bool) {
if let Err(e) = self.pad_button(event_loop, button, down) {
log::warn!("Horizon: controller {}: {e}", button.name());
@@ -287,7 +376,14 @@ impl Game<'_> {
}
// Releases always reach the mappings, so nothing stays held when a
// screen opened in between.
for action in self.settings.controller.actions_of(button) {
let actions = if down {
let actions = self.settings.controller.actions_of(button);
self.pad.actions[button as usize] = actions.clone();
actions
} else {
std::mem::take(&mut self.pad.actions[button as usize])
};
for action in actions {
self.pad_action(action, down)?;
}
Ok(())
@@ -296,15 +392,18 @@ impl Game<'_> {
/// A bound action: a vanilla mapping acts as its default key or mouse
/// button, like a rebound keyboard key.
fn pad_action(&mut self, action: &str, down: bool) -> Result<()> {
match action {
gamepad::HOTBAR_PREVIOUS | gamepad::HOTBAR_NEXT => {
let Some(route) = gamepad::route(action) else {
log::warn!("Horizon: controller action {action} has no handler");
return Ok(());
};
match route {
Route::HotbarStep { next } => {
if down && self.screen == Screen::Playing {
self.selected =
gamepad::hotbar_step(self.selected, action == gamepad::HOTBAR_NEXT);
self.selected = gamepad::hotbar_step(self.selected, next);
}
Ok(())
}
"key.sneak"
Route::Sneak
if self.settings.controller.toggle_sneak && !self.settings.toggle_crouch =>
{
// A stick click flips sneaking instead of holding it, as
@@ -314,23 +413,42 @@ impl Game<'_> {
return Ok(());
}
let screen_open = self.screen != Screen::Playing || self.title.is_some();
let was = self.keys.contains(&KeyCode::ShiftLeft);
self.toggles.pad_toggle(SNEAK, screen_open);
self.sync_toggle(SNEAK);
if !was && self.keys.contains(&KeyCode::ShiftLeft) {
// The pad's latch: a keyboard handoff leaves it.
self.keys.press(KeyCode::ShiftLeft, Source::Pad);
self.keys.release(&KeyCode::ShiftLeft, Source::Keyboard);
}
self.key_pressed(KeyCode::ShiftLeft, KeyCode::ShiftLeft)
}
_ => {
let default = KeyBinds::default_of(action);
if let Some(button) = mouse_button(default) {
if self.title.is_none() && self.screen == Screen::Playing {
self.play_mouse(button, down)?;
}
Ok(())
} else if let Some(key) = key_code(default) {
self.mapped_key(key, down, false, key)
} else {
Ok(())
Route::Sneak => self.mapped_key(
KeyCode::ShiftLeft,
down,
false,
KeyCode::ShiftLeft,
Source::Pad,
),
Route::Screenshot => {
if down {
self.screenshot_action();
}
Ok(())
}
Route::SmoothCamera => {
if down {
self.smooth_camera_action();
}
Ok(())
}
Route::Mouse(button) => {
if !down || (self.title.is_none() && self.screen == Screen::Playing) {
self.play_mouse(button, down)?;
}
Ok(())
}
Route::Key(key) => self.mapped_key(key, down, false, key, Source::Pad),
}
}
@@ -382,7 +500,7 @@ impl Game<'_> {
if yaw == 0.0 && pitch == 0.0 {
return;
}
self.turn_player(f64::from(yaw / 0.15), f64::from(pitch / 0.15));
self.look_turn(f64::from(yaw / 0.15), f64::from(pitch / 0.15));
}
/// Vibrate the active pad, when vibration is on and the pad is the one
@@ -0,0 +1,83 @@
//! A controller action does what its keyboard key does, on a real `Game` without a window.
//! What needs a renderer (inventory slots, screenshots) is covered by tools/pad_parity_e2e.py.
use super::super::{Audio, Game, Options, Progress, Screen, Settings, load_resources};
use horizon_client::gamepad::{Button, PadState};
use horizon_client::held::Source;
use winit::keyboard::KeyCode;
/// A game at play with no world: input reaches the mappings, as in `Screen::Playing`.
fn playing(test: impl FnOnce(&mut Game)) {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.title = None;
game.screen = Screen::Playing;
test(&mut game);
}
/// Hold `button` on the pad, as a poll that saw it go down would.
fn pad_press(game: &mut Game, button: Button, action: &str) {
let mut state = PadState::default();
state.set(button, true);
game.pad.edges.update(&state, 0.4);
game.pad_action(action, true).unwrap();
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn keyboard_handoff_keeps_what_the_pad_holds() {
playing(|game| {
game.mapped_key(KeyCode::KeyW, true, false, KeyCode::KeyW, Source::Keyboard)
.unwrap();
pad_press(game, Button::South, "key.jump");
assert!(game.keys.contains(&KeyCode::Space) && game.keys.contains(&KeyCode::KeyW));
// Splitscreen: keyboard and mouse move to another player.
game.release_keyboard_mouse();
assert!(
!game.keys.contains(&KeyCode::KeyW),
"the keyboard's walk ends"
);
assert!(
game.keys.contains(&KeyCode::Space),
"the pad still holds jump; only the keyboard and mouse left"
);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn pad_smooth_camera_binding_toggles_the_cinematic_camera() {
playing(|game| {
game.settings
.controller
.bind("key.smoothCamera", Some(Button::Select));
assert!(!game.smooth_camera);
pad_press(game, Button::Select, "key.smoothCamera");
assert!(game.smooth_camera, "as its key does in play");
});
}
+218 -98
View File
@@ -1,20 +1,19 @@
//! A controller in menus, screens and inventories (Horizon's own;
//! docs/controllers.md). Two ways to drive them, picked in the controller
//! options:
//! docs/controllers.md). Each screen is driven one way:
//!
//! - **Focus** (console-style): a highlight steps between the widgets, list
//! rows, fields and slots each screen registers (`widgets::target_as`)
//! with vanilla's arrow-key navigation; A presses the focused one, the
//! bumpers switch tabs, left/right move a focused slider, A on a text field
//! opens the on-screen keyboard (`osk`).
//! - **Pointer**: a virtual cursor the left stick moves and the D-pad steps
//! between slots and buttons.
//! - **Focus** (menus): a highlight steps between the widgets, list rows and
//! fields each screen registers (`widgets::target_as`) with vanilla's
//! arrow-key navigation; A presses the focused one, the bumpers switch tabs,
//! left/right move a focused slider, A on a text field opens the on-screen
//! keyboard (`osk`).
//! - **Crosshair** (inventories, containers and the screens whose clickable
//! parts register no target): a pointer the left stick moves and the D-pad
//! steps between slots and buttons.
//!
//! Both feed the screens the same pointer events a mouse sends (the pointer
//! sits on the focused widget, hidden), so every screen keeps its mouse
//! behaviour (drags, sliders, scrollbars, hover highlights) with no pad code
//! of its own. A screen with nothing registered falls back to the pointer,
//! and a right stick click swaps modes for the open screen.
//! of its own.
use super::event_loop::EventLoopCtl;
use super::{
Game, Screen,
@@ -23,13 +22,14 @@ use super::{
use horizon_client::{
Result,
creative::Creative,
gamepad::{self, Button, MenuNav, Rect},
hud::{HudBatch, rgba},
gamepad::{self, Button, Rect},
hud::HudBatch,
options_ui::{Event, Page},
osk::{self, Keyboard},
screen_narrator::InputType,
widgets::{self, Kind, Target},
};
use horizon_client_input::snap;
use std::time::Instant;
use winit::{
dpi::PhysicalPosition,
@@ -38,11 +38,6 @@ use winit::{
window::WindowId,
};
/// D-pad auto-repeat: the first step at once, the next after a pause.
const REPEAT_DELAY: f32 = 0.35;
const REPEAT_EVERY: f32 = 0.09;
/// Lines per second the right stick scrolls at full tilt.
const SCROLL_SPEED: f32 = 12.0;
/// How far the stick must lean to step focus.
const STICK_STEP: f32 = 0.55;
/// Scrolls a step may try to reveal the next row of a list.
@@ -69,6 +64,10 @@ pub(super) struct MenuCursor {
last_poll: Option<Instant>,
/// The stick moved the cursor last poll (it settles when let go).
moving: bool,
/// Gliding onto a slot or control: from, to, since.
glide: Option<((f32, f32), (f32, f32), Instant)>,
/// Where the stick's current movement started.
gesture: Option<(f32, f32)>,
wheel: [f32; 2],
/// The held D-pad direction and when it last stepped.
repeat: Option<(Button, Instant, bool)>,
@@ -82,8 +81,6 @@ pub(super) struct MenuCursor {
focus: Option<Target>,
/// The left stick's step direction last poll.
stick: Option<Button>,
/// A right stick click swapped modes for this screen.
swapped: bool,
pending: Option<Pending>,
/// The focused slider being nudged and where the pad put its handle.
nudge: Option<(Rect, f32)>,
@@ -110,6 +107,9 @@ pub(super) struct MenuCursor {
/// new screen's first drawn frame.
key_screen: u64,
key_restart: Option<u64>,
/// The pad's focus on each screen it left (`Game::key_screen`), so coming
/// back lands where it was, as vanilla keeps a screen's focused widget.
remembered: std::collections::HashMap<u64, Rect>,
}
impl MenuCursor {
@@ -139,10 +139,21 @@ impl Game<'_> {
.map(|r| HudBatch::new([r.config.width, r.config.height]))
}
/// Focus mode is on for the open screen (the keyboard always focuses).
/// Focus drives the open screen: every menu but inventories and
/// containers, and the screens whose clickable parts register no focus
/// target (the advancements map, the statistics lists, the pack rows, a
/// book's page text), where the pointer does (the keyboard always focuses).
pub(super) fn pad_navigation_mode(&self) -> bool {
let pointer = matches!(
self.screen,
Screen::Inventory | Screen::Creative | Screen::Stats | Screen::Advancements
) || self.book.is_some()
|| (self.screen == Screen::Options && self.ui.options.page() == Page::Packs);
!pointer
}
fn pad_focus_mode(&self) -> bool {
self.pad.menu.keyboard
|| (self.settings.controller.menu_nav == MenuNav::Focus) != self.pad.menu.swapped
self.pad.menu.keyboard || self.pad_navigation_mode()
}
/// Where focus can land: buttons drawn last frame, every registered
@@ -246,6 +257,18 @@ impl Game<'_> {
// Screen.setInitialFocus: a forward Tab from nothing.
return key_focus::tab_step(targets, None, true).map(|i| targets[i]);
}
// Back on a screen the pad left: where it was, as vanilla keeps a
// screen's focused widget (Options, after its Done).
let rects: Vec<Rect> = targets.iter().map(|t| t.rect).collect();
if let Some(i) = self
.pad
.menu
.remembered
.get(&self.key_screen())
.and_then(|r| gamepad::refind(*r, &rects, 2.0))
{
return Some(targets[i]);
}
let batch = self.gui_scale()?;
let at = |x: f32, y: f32| {
targets
@@ -271,12 +294,32 @@ impl Game<'_> {
_ => {}
}
}
targets
.iter()
.find(|t| t.kind == Kind::Button)
.or_else(|| targets.iter().find(|t| !matches!(t.kind, Kind::Tab(_))))
.or(targets.first())
.copied()
// Screen.setInitialFocus, as the keyboard's: the first control in tab
// order (Singleplayer on the title, the first option row), not
// whatever happened to register first.
key_focus::tab_step(targets, None, true)
.map(|i| targets[i])
.or_else(|| {
targets
.iter()
.find(|t| !matches!(t.kind, Kind::Tab(_)))
.copied()
})
.or(targets.first().copied())
}
/// The pointer is over a slot or control.
fn pad_over_target(&self) -> bool {
let (x, y) = self.cursor;
self.pad_targets().iter().any(|t| {
let [tx, ty, w, h] = t.rect;
(tx..tx + w).contains(&x) && (ty..ty + h).contains(&y)
})
}
/// Start the cursor's glide onto `to` (GUI pixels), from wherever it is.
fn pad_glide_to(&mut self, to: (f32, f32)) {
self.pad.menu.glide = Some((self.cursor, to, Instant::now()));
}
/// Send the window a pointer move to `at` (GUI pixels), as the mouse would.
@@ -387,6 +430,25 @@ impl Game<'_> {
self.pad_pointer_to(event_loop, at);
}
/// Run `f` with `key` held as a keyboard holds it, in the modifier word
/// screens read as well as the physical keys, then put both back. The
/// pad's only way to send Shift or Ctrl, so it can't fall out of step
/// with what the keyboard sends.
fn pad_with_modifier<T>(&mut self, key: KeyCode, f: impl FnOnce(&mut Self) -> T) -> T {
let held = horizon_client_input::modifiers::hold(
&mut self.modifiers,
&mut self.physical_keys,
key,
);
let result = f(self);
horizon_client_input::modifiers::restore(
&mut self.modifiers,
&mut self.physical_keys,
held,
);
result
}
/// Focus mode with something to focus on this screen.
fn pad_focusing(&self) -> bool {
self.pad_focus_mode() && self.pad.menu.focus.is_some()
@@ -459,15 +521,6 @@ impl Game<'_> {
}
match button {
Button::East | Button::Start => return self.pad_back(),
Button::RightStick => {
// Swap focus and pointer for this screen.
self.pad.menu.swapped = !self.pad.menu.swapped;
self.pad.menu.focus = None;
self.pad.menu.pending = None;
self.pad.menu.nudge = None;
self.pad.menu.pos = None;
self.pad_take_pointer(event_loop);
}
Button::South if self.pad_focusing() => {
let focus = self.pad.menu.focus.expect("focusing");
match focus.kind {
@@ -500,21 +553,13 @@ impl Game<'_> {
self.pad_take_pointer(event_loop);
let in_keys = self.keys.contains(&KeyCode::ShiftLeft);
self.keys.insert(KeyCode::ShiftLeft);
let held = horizon_client_input::modifiers::hold(
&mut self.modifiers,
&mut self.physical_keys,
KeyCode::ShiftLeft,
);
self.pad_pointer_button(event_loop, false, true);
self.pad_pointer_button(event_loop, false, false);
self.pad_with_modifier(KeyCode::ShiftLeft, |game| {
game.pad_pointer_button(event_loop, false, true);
game.pad_pointer_button(event_loop, false, false);
});
if !in_keys {
self.keys.remove(&KeyCode::ShiftLeft);
}
horizon_client_input::modifiers::restore(
&mut self.modifiers,
&mut self.physical_keys,
held,
);
}
// View / left stick in an inventory: throw one / the stack from
// the slot under focus (Q / Ctrl+Q).
@@ -523,16 +568,13 @@ impl Game<'_> {
&& self.title.is_none() =>
{
self.pad_take_pointer(event_loop);
let all = button == Button::LeftStick;
let ctrl = self.physical_keys.contains(&KeyCode::ControlLeft);
if all {
self.physical_keys.insert(KeyCode::ControlLeft);
if button == Button::LeftStick {
self.pad_with_modifier(KeyCode::ControlLeft, |game| {
game.key_pressed(KeyCode::KeyQ, KeyCode::KeyQ)
})?;
} else {
self.key_pressed(KeyCode::KeyQ, KeyCode::KeyQ)?;
}
let result = self.key_pressed(KeyCode::KeyQ, KeyCode::KeyQ);
if all && !ctrl {
self.physical_keys.remove(&KeyCode::ControlLeft);
}
result?;
}
Button::DPadUp | Button::DPadDown | Button::DPadLeft | Button::DPadRight => {
self.pad.menu.repeats = 0;
@@ -593,7 +635,7 @@ impl Game<'_> {
let from = self.cursor;
let centres: Vec<(f32, f32)> = self.pad_targets().iter().map(Target::center).collect();
if let Some(i) = gamepad::focus_step(from, button, &centres) {
self.pad_pointer_to(event_loop, centres[i]);
self.pad_glide_to(centres[i]);
}
}
@@ -677,6 +719,9 @@ impl Game<'_> {
fn pad_focus_on(&mut self, event_loop: &dyn EventLoopCtl, target: Target) {
let left = self.pad.menu.focus.replace(target);
if left.is_some_and(|l| l.rect != target.rect) {
self.clear_screen_click_focus();
}
self.pad.menu.pending = None;
self.pad.menu.nudge = None;
if !self.pad.menu.held[0] {
@@ -692,6 +737,38 @@ impl Game<'_> {
self.narration.keyboard_action();
}
/// The pad leaves screen `screen`: remember its focus there for when it
/// comes back. Called before the new screen's targets are read, so the
/// focus is still the left screen's.
pub(super) fn pad_remember_focus(&mut self, screen: u64) {
let Some(focus) = self.pad.menu.focus else {
return;
};
let remembered = &mut self.pad.menu.remembered;
if remembered.len() > 32 && !remembered.contains_key(&screen) {
remembered.clear();
}
remembered.insert(screen, focus.rect);
}
/// Vanilla's arrow navigation moves a screen's focus off the widget a
/// click focused: the screens that draw that click focus themselves drop
/// it, so only the pad's focus shows.
fn clear_screen_click_focus(&mut self) {
if let Some(title) = self.title.as_mut() {
title.focused_button = None;
}
match self.screen {
Screen::Paused => self.pause_focus = None,
Screen::Dead => self.death_focus = None,
Screen::Options => self.ui.options.clear_click_focus(),
_ => {}
}
if let Some(options) = self.world_options.as_mut() {
options.clear_click_focus();
}
}
/// The controller's focus ring in focus mode, for the screen's narration:
/// the focused widget.
pub(super) fn pad_focus_rect(&self) -> Option<[f32; 4]> {
@@ -783,7 +860,6 @@ impl Game<'_> {
menu.repeat = None;
menu.held = [false; 2];
menu.focus = None;
menu.swapped = false;
menu.pending = None;
menu.nudge = None;
menu.osk = None;
@@ -802,12 +878,16 @@ impl Game<'_> {
if std::mem::take(&mut self.pad.menu.keyboard) {
self.pad.menu.origin = focus.map(|t| t.rect);
}
self.pad.in_use = false;
self.pad_yield_input(event_loop);
}
// Focus lands on the menu as it opens when the pad was in use.
if self.pad.in_use && self.pad.menu.pos.is_none() && self.pad.menu.open_polls == 3 {
self.pad_take_pointer(event_loop);
}
if !self.pad.in_use && !self.pad.menu.keyboard {
self.pad_show_os_pointer(true);
return;
}
let focus_mode = self.pad_focus_mode();
let targets = if focus_mode {
self.pad_targets()
@@ -846,26 +926,40 @@ impl Game<'_> {
}
} else {
self.pad.menu.stick = None;
let (dx, dy) = gamepad::cursor_delta(state.left, move_deadzone, seconds);
let tuning = &snap::TUNING;
let mut speed = seconds * self.settings.controller.pointer_speed;
if tuning.over_target_speed != 1.0 && self.pad_over_target() {
speed *= tuning.over_target_speed;
}
let (dx, dy) = gamepad::cursor_delta(state.left, move_deadzone, speed);
if dx != 0.0 || dy != 0.0 {
self.pad.menu.glide = None;
self.pad_take_pointer(event_loop);
let at = self.cursor;
self.pad.menu.gesture.get_or_insert(at);
self.pad_pointer_to(event_loop, (at.0 + dx, at.1 + dy));
self.pad.menu.moving = true;
} else if self.pad.menu.moving {
// Let go: settle onto a slot or button close by.
// Let go: the snapping engine picks where it settles.
self.pad.menu.moving = false;
let start = self.pad.menu.gesture.take().unwrap_or(self.cursor);
let centres: Vec<(f32, f32)> =
self.pad_targets().iter().map(Target::center).collect();
if let Some(i) = gamepad::nearest(self.cursor, &centres, 10.0) {
self.pad_pointer_to(event_loop, centres[i]);
if let Some(i) = snap::settle(start, self.cursor, &centres, tuning) {
self.pad_glide_to(centres[i]);
}
} else if let Some((from, to, since)) = self.pad.menu.glide {
let elapsed = now.duration_since(since).as_secs_f32();
self.pad_pointer_to(event_loop, snap::glide(from, to, elapsed, tuning));
if elapsed >= tuning.glide_seconds {
self.pad.menu.glide = None;
}
}
}
let scroll = gamepad::radial(state.right, look_deadzone);
let wheel = &mut self.pad.menu.wheel;
wheel[0] += scroll[0] * SCROLL_SPEED * seconds;
wheel[1] += scroll[1] * SCROLL_SPEED * seconds;
wheel[0] += scroll[0] * self.settings.controller.scroll_speed * seconds;
wheel[1] += scroll[1] * self.settings.controller.scroll_speed * seconds;
if scroll == [0.0, 0.0] {
*wheel = [0.0, 0.0];
}
@@ -878,9 +972,9 @@ impl Game<'_> {
}
if let Some((button, since, repeating)) = self.pad.menu.repeat {
let wait = if repeating {
REPEAT_EVERY
self.settings.controller.repeat_interval
} else {
REPEAT_DELAY
self.settings.controller.repeat_delay
};
if now.duration_since(since).as_secs_f32() >= wait {
self.pad.menu.repeat = Some((button, now, true));
@@ -1013,11 +1107,35 @@ impl Game<'_> {
"focus": self.pad.menu.focus.as_ref().map(target),
"keyboard": self.pad.menu.osk.is_some(),
"targets": self.pad_targets().iter().map(target).collect::<Vec<_>>(),
"screen_key": format!("{:016x}", self.key_screen()),
"remembered": self
.pad
.menu
.remembered
.iter()
.map(|(k, r)| (format!("{k:016x}"), serde_json::json!(r)))
.collect::<serde_json::Map<_, _>>(),
})
}
// ---- the keyboard: vanilla's focus navigation (`key_focus`) ----
pub(super) fn pad_cancel_menu(&mut self, event_loop: &dyn EventLoopCtl) {
for right in [false, true] {
if self.pad.menu.held[usize::from(right)] {
self.pad_pointer_button(event_loop, right, false);
}
}
self.pad.menu.repeat = None;
self.pad.menu.stick = None;
self.pad.menu.wheel = [0.0; 2];
self.pad.menu.moving = false;
self.pad.menu.glide = None;
self.pad.menu.gesture = None;
self.pad.menu.pending = None;
self.pad.menu.nudge = None;
}
/// The keyboard drives the open menu's focus.
pub(super) fn key_focus_driving(&self) -> bool {
self.pad.menu.keyboard && self.pad_in_menu()
@@ -1157,10 +1275,11 @@ impl Game<'_> {
}
/// Which screen is open, as far as the keyboard's focus cares.
fn key_screen(&self) -> u64 {
pub(super) fn key_screen(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
std::mem::discriminant(&self.screen).hash(&mut hasher);
self.remote_menu_id().hash(&mut hasher);
if self.screen == Screen::Options {
std::mem::discriminant(&self.ui.options.page()).hash(&mut hasher);
}
@@ -1247,6 +1366,18 @@ impl Game<'_> {
if self.pad.menu.synthetic {
return;
}
let takeover = match event {
WindowEvent::CursorMoved { position, .. } => {
self.pad.menu.real_pointer != Some(*position)
}
WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
WindowEvent::MouseWheel { .. } => true,
_ => false,
};
if takeover {
self.pad_yield_input(event_loop);
}
match event {
WindowEvent::CursorMoved { position, .. } => {
self.pad.menu.real_pointer = Some(*position);
@@ -1480,6 +1611,9 @@ impl Game<'_> {
ui.finish();
return;
}
if !self.pad.in_use && !self.pad.menu.keyboard {
return;
}
let Some((x, y)) = self.pad.menu.pos else {
return;
};
@@ -1489,33 +1623,19 @@ impl Game<'_> {
self.draw_pad_focus(batch, focus);
return;
}
// An arrow, outlined, pointing up-left at (x, y).
let atlas = &self.gui.atlas;
let (x, y) = (x.floor(), y.floor());
const ROWS: [u8; 10] = [1, 2, 3, 4, 5, 6, 7, 4, 2, 1];
for (i, &w) in ROWS.iter().enumerate() {
let row = y + i as f32;
batch.fill(
atlas,
x - 1.0,
row,
f32::from(w) + 2.0,
1.0,
rgba(0, 0, 0, 255),
);
}
batch.fill(atlas, x - 1.0, y + 10.0, 2.0, 1.0, rgba(0, 0, 0, 255));
for (i, &w) in ROWS.iter().enumerate().skip(1).take(8) {
let row = y + i as f32;
batch.fill(
atlas,
x,
row,
f32::from(w) - 1.0,
1.0,
rgba(255, 255, 255, 255),
);
}
// A crosshair centred on the point it clicks: inventories, and a
// screen with nothing to focus.
use horizon_client::pad_cursor;
let (left, top) = pad_cursor::origin((x, y));
batch.sprite_scaled(
&self.gui.atlas,
pad_cursor::SPRITE,
left,
top,
pad_cursor::DRAWN,
pad_cursor::DRAWN,
u32::MAX,
);
}
/// The focus highlight, as vanilla's keyboard focus looks: buttons,
+186 -59
View File
@@ -195,14 +195,18 @@ fn menu_title(tag: &Tag) -> horizon_client::text::Text {
}
/// A player message Chat Delay holds back, its signature and trust level,
/// and what the narrator says of it once it shows.
type QueuedChat = (
horizon_client::text::Text,
horizon_client::chat::ChatSource,
Option<Signature>,
ChatTrust,
String,
);
/// what the narrator says of it once it shows, and who sent it in which
/// chat type, for mods told when it shows (`ClientChatReceivedEvent`).
struct QueuedChat {
line: horizon_client::text::Text,
signature: Option<Signature>,
trust: ChatTrust,
narration: String,
/// The player's UUID, 0 for disguised chat (`Util.NIL_UUID`).
sender: u128,
/// The chat type's network ID (`ChatType.Bound.chatType`).
chat_type: i32,
}
/// `ChatTrustLevel`; a modified message keeps the signed content it was
/// changed from.
@@ -537,6 +541,9 @@ pub(super) struct Remote {
pub(super) menu_title: Option<horizon_client::text::Text>,
state_id: i32,
use_target: Option<[i32; 3]>,
/// A mod menu's extra data, kept for the `OpenScreen` of its window that
/// follows (`horizon:menu_extra`).
menu_extra: Option<horizon_protocol::menu_extra::Message>,
entities: HashMap<i32, Tracked>,
lerps: HashMap<i32, Lerp>,
/// The minecarts the server moves with `ClientboundMoveMinecartPacket`
@@ -1039,6 +1046,7 @@ impl Game<'_> {
menu_title: None,
state_id: 0,
use_target: None,
menu_extra: None,
entities: HashMap::new(),
lerps: HashMap::new(),
cart_lerps: HashMap::new(),
@@ -1398,10 +1406,6 @@ impl Game<'_> {
signature: Option<Signature>,
trust: ChatTrust,
) -> bool {
// ChatListener.showMessageToPlayer: Only Show Secure Chat drops what is not secure.
if matches!(trust, ChatTrust::NotSecure) && self.settings.file.bool("onlyShowSecureChat") {
return false;
}
if !self.ui.chat.shows(source) {
return false;
}
@@ -1442,21 +1446,13 @@ impl Game<'_> {
/// `ChatListener.handleMessage` for player and disguised chat: held in the
/// queue while Chat Delay is set, shown at once otherwise. `narration` is
/// the chat type's narration of it (`decorateNarration`).
pub(super) fn handle_player_chat(
&mut self,
line: horizon_client::text::Text,
signature: Option<Signature>,
trust: ChatTrust,
narration: String,
) {
let source = horizon_client::chat::ChatSource::Player;
fn handle_player_chat(&mut self, chat: QueuedChat) {
if self.chat_delay_ms() > 0 {
if let Some(r) = self.remote.as_mut() {
r.chat_queue
.push_back((line, source, signature, trust, narration));
r.chat_queue.push_back(chat);
}
} else {
self.accept_chat(line, source, signature, trust, &narration);
self.accept_chat(chat);
}
}
@@ -1484,7 +1480,14 @@ impl Game<'_> {
let narration = self
.decorate_chat(chat_type, &error, Decoration::Narration)
.plain();
self.handle_player_chat(line, None, ChatTrust::Error, narration);
self.handle_player_chat(QueuedChat {
line,
signature: None,
trust: ChatTrust::Error,
narration,
sender: 0,
chat_type: chat_type.chat_type,
});
}
/// `ChatTrustLevel.evaluate` of a player message, with
@@ -1550,7 +1553,7 @@ impl Game<'_> {
r.secure_chat.ignore_pending(&signature);
let queued = r.chat_queue.len();
r.chat_queue
.retain(|(_, _, s, _, _)| s.as_ref() != Some(&signature));
.retain(|chat| chat.signature.as_ref() != Some(&signature));
if r.chat_queue.len() == queued {
self.ui
.chat
@@ -1561,15 +1564,9 @@ impl Game<'_> {
/// A message's `accept`: shown, it is narrated (`narrateChatMessage`) and
/// starts the Chat Delay wait again.
fn accept_chat(
&mut self,
line: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
signature: Option<Signature>,
trust: ChatTrust,
narration: &str,
) -> bool {
let shown = self.player_chat_line(line, source, signature, trust, narration);
fn accept_chat(&mut self, chat: QueuedChat) -> bool {
let signature = chat.signature;
let shown = self.player_chat_line(chat);
if let Some(signature) = &signature {
self.mark_message_processed(signature, shown);
}
@@ -1579,28 +1576,60 @@ impl Game<'_> {
shown
}
/// `showMessageToPlayer`: the line joins the chat if the Chat option and
/// Only Show Secure Chat let it through, and the narrator says it
/// (`sayChatQueued`).
fn player_chat_line(
&mut self,
line: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
signature: Option<Signature>,
trust: ChatTrust,
narration: &str,
) -> bool {
// handleChatMessageError says its line as system chat.
/// `showMessageToPlayer`: the line joins the chat if Only Show Secure
/// Chat, mods and the Chat option let it through, and the narrator says
/// it (`sayChatQueued`).
fn player_chat_line(&mut self, chat: QueuedChat) -> bool {
let QueuedChat {
mut line,
signature,
trust,
narration,
sender,
chat_type,
} = chat;
// ChatListener.showMessageToPlayer: Only Show Secure Chat drops what is not secure.
if matches!(trust, ChatTrust::NotSecure) && self.settings.file.bool("onlyShowSecureChat") {
return false;
}
// handleChatMessageError says its line as system chat, and mods are not told of it.
let error = matches!(trust, ChatTrust::Error);
if !error && !self.chat_received(false, false, sender, Some(chat_type), &mut line) {
return false;
}
let source = horizon_client::chat::ChatSource::Player;
let shown = self.signed_chat_line(line, source, signature, trust);
if shown && error {
self.say_system_chat_queued(&horizon_client::text::Text::literal(narration));
self.say_system_chat_queued(&horizon_client::text::Text::literal(&narration));
} else if shown {
self.say_chat_queued(narration);
self.say_chat_queued(&narration);
}
shown
}
/// NeoForge's `ClientChatReceivedEvent` (`ClientHooks.onClientChat`,
/// `onClientPlayerChat` and `onClientSystemChat`): mods may replace
/// `line` or cancel it. False when cancelled.
fn chat_received(
&mut self,
system: bool,
overlay: bool,
sender: u128,
chat_type: Option<i32>,
line: &mut horizon_client::text::Text,
) -> bool {
if horizon_client::mod_api::mods().is_empty() {
return true;
}
let registries = self.remote.as_ref().map(|r| Arc::clone(&r.registries));
let name = chat_type
.zip(registries.as_deref())
.and_then(|(id, registries)| registries.name("minecraft:chat_type", id));
let mut cancel = false;
self.native_client_chat_received(system, overlay, sender, name, line, &mut cancel);
!cancel
}
/// `ChatListener.messageDelay`: Chat Delay in milliseconds, as Java
/// truncates it.
fn chat_delay_ms(&self) -> i64 {
@@ -1640,10 +1669,8 @@ impl Game<'_> {
}
// A message the Chat option hides is dropped and the next one is tried,
// until one shows.
while let Some((line, source, signature, trust, narration)) =
self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
{
if self.accept_chat(line, source, signature, trust, &narration) {
while let Some(chat) = self.remote.as_mut().and_then(|r| r.chat_queue.pop_front()) {
if self.accept_chat(chat) {
break;
}
}
@@ -1659,8 +1686,8 @@ impl Game<'_> {
else {
return;
};
for (line, source, signature, trust, narration) in queue {
self.accept_chat(line, source, signature, trust, &narration);
for chat in queue {
self.accept_chat(chat);
}
if let Some(r) = self.remote.as_mut() {
r.chat_previous = None;
@@ -3051,6 +3078,18 @@ impl Game<'_> {
C::CustomPayload(p) if p.channel == horizon_protocol::attachment_sync::CHANNEL => {
self.remote_attachment_sync(&p.data);
}
// A mod menu type's block and its mod's bytes, just before the
// menu's `OpenScreen`.
C::CustomPayload(p) if p.channel == horizon_protocol::menu_extra::CHANNEL => {
match horizon_protocol::menu_extra::decode(&p.data) {
Ok(extra) => {
if let Some(r) = self.remote.as_mut() {
r.menu_extra = Some(extra);
}
}
Err(e) => log::debug!("Horizon: a bad menu extra payload: {e}"),
}
}
// The server's recipes its native mods asked to send.
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
self.remote_recipe_content(&p.data);
@@ -4566,8 +4605,12 @@ impl Game<'_> {
}
C::SystemChat(p) => {
// The component as sent, translated in the client's own
// language. ChatListener.handleOverlay for the action bar.
let text = remote_text(&p.content);
// language; mods may change or cancel it. ChatListener.handleOverlay
// for the action bar.
let mut text = remote_text(&p.content);
if !self.chat_received(true, p.overlay, 0, None, &mut text) {
return Ok(());
}
if p.overlay {
self.say_system_queued(&text);
self.titles.set_overlay(text, false);
@@ -4683,7 +4726,14 @@ impl Game<'_> {
}
return Ok(());
}
self.handle_player_chat(line, signature, trust, narration);
self.handle_player_chat(QueuedChat {
line,
signature,
trust,
narration,
sender: p.sender,
chat_type: p.chat_type.chat_type,
});
}
C::DisguisedChat(p) => {
let content = horizon_client::scoreboard::tag_to_json(&p.message);
@@ -4691,7 +4741,14 @@ impl Game<'_> {
.decorate_chat(&p.chat_type, &content, Decoration::Narration)
.plain();
let line = self.decorate_chat(&p.chat_type, &content, Decoration::Chat);
self.handle_player_chat(line, None, ChatTrust::Secure, narration);
self.handle_player_chat(QueuedChat {
line,
signature: None,
trust: ChatTrust::Secure,
narration,
sender: 0,
chat_type: p.chat_type.chat_type,
});
}
C::DeleteChat(p) => self.handle_delete_chat(&p.signature)?,
C::PlayerInfoUpdate(p) => {
@@ -9438,6 +9495,18 @@ impl Game<'_> {
.as_mut()
.and_then(|r| r.use_target.take())
.map(|t| (t[0], t[1], t[2]));
// The server names a mod menu's block itself: it, not the last
// clicked block, is the one the menu is of. Only for this window:
// any other's is stale.
let extra = self
.remote
.as_mut()
.and_then(|r| r.menu_extra.take())
.filter(|e| e.window == p.container);
let target = extra
.as_ref()
.map(|e| (e.pos[0], e.pos[1], e.pos[2]))
.or(target);
if menu == "minecraft:lectern" {
// LecternScreen: the book and page come in the menu's content and data.
if let Some(r) = self.remote.as_mut() {
@@ -9545,7 +9614,7 @@ impl Game<'_> {
let key = target.filter(holds).unwrap_or((i32::MIN, 0, 0));
self.world.block_entities.insert(key, entity);
self.container = Some(key);
self.open_native_screen(key, target, p.container, &menu);
self.open_native_screen(key, target, p.container, &menu, extra);
// A mod menu type shows through its mod's screen only: without one
// (`MenuScreens.create` finds no screen) the window closes again.
if self.mod_screen.is_none() && mod_type.is_some() {
@@ -10568,6 +10637,64 @@ mod tests {
};
use horizon_world::nbt::Tag;
/// A mod's chat goes out only from a joined player, and only what the
/// Chat option allows.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn mod_chat_needs_a_joined_player_and_what_the_chat_option_allows() {
use super::super::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use horizon_protocol::connection::Pipe;
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!("no menu handoff"),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
assert!(game.native_send_chat("/warp spawn").is_err());
let (client, _server) = Pipe::pair();
game.connect_local(client, "ChatTest").unwrap();
assert!(game.native_send_chat("/warp spawn").is_err());
game.remote.as_mut().unwrap().joined = true;
assert!(game.native_send_chat(" ").is_err());
// Full, then Commands Only, then Hidden.
game.settings.chat_visibility = 0;
assert!(game.native_send_chat("hello").is_ok());
assert!(game.native_send_chat("/warp spawn").is_ok());
game.settings.chat_visibility = 1;
assert!(game.native_send_chat("hello").is_err());
assert!(game.native_send_chat("/warp spawn").is_ok());
game.settings.chat_visibility = 2;
assert!(game.native_send_chat("/warp spawn").is_err());
}
/// `MultiPlayerGameMode.useItem`'s `shouldSwing`: the SUCCESS answers
/// swing, CONSUME's do not (#6425).
#[test]
+56 -31
View File
@@ -289,37 +289,7 @@ impl<'a> Split<'a> {
if next != self.focus {
self.turn(self.focus);
if let Some(game) = self.game_mut(self.focus) {
for key in std::mem::take(&mut game.physical_keys) {
game.native_key_event(key, false, false);
}
for key in std::mem::take(&mut game.keys) {
if let Err(e) = game.mapped_key(key, false, false, key) {
log::warn!("Horizon: splitscreen: releasing input: {e}");
}
}
for button in std::mem::take(&mut game.physical_buttons) {
game.extras_mouse(button, false);
}
game.drag_button = None;
game.modifiers = Default::default();
game.left = false;
game.right = false;
game.inventory_mouse_release(false);
game.inventory_mouse_release(true);
game.inventory_pick_release();
game.loom_release();
game.creative_screen.release();
game.ui.options.release();
game.dragging = false;
game.stats_release();
game.world_options_release();
game.advancements_mouse(false, [0, 0]);
if let Some(title) = &mut game.title {
title.release();
title.release_field();
}
game.capture(false);
game.keyboard_mouse = false;
game.release_keyboard_mouse();
}
}
self.focus = next;
@@ -405,6 +375,10 @@ impl<'a> Split<'a> {
batching,
self.host.settings.horizon.wide_frame(),
shader_pack,
(
self.host.settings.file.int("textureFiltering"),
self.host.settings.file.int("maxAnisotropyBit"),
),
);
let renderer = match built {
Ok(r) => r,
@@ -483,6 +457,10 @@ impl<'a> Split<'a> {
batching,
self.host.settings.horizon.wide_frame(),
shader_pack,
(
self.host.settings.file.int("textureFiltering"),
self.host.settings.file.int("maxAnisotropyBit"),
),
);
let renderer = match built {
Ok(r) => r,
@@ -871,3 +849,50 @@ impl ApplicationHandler for Split<'_> {
self.turn(0);
}
}
impl Game<'_> {
/// Keyboard and mouse go to another player: release everything they held here.
pub(super) fn release_keyboard_mouse(&mut self) {
for key in std::mem::take(&mut self.physical_keys) {
self.native_key_event(key, false, false);
}
// Only what the keyboard held: this player's pad keeps its presses.
for key in self
.keys
.release_source(horizon_client::held::Source::Keyboard)
{
if let Err(e) = self.mapped_key(
key,
false,
false,
key,
horizon_client::held::Source::Keyboard,
) {
log::warn!("Horizon: splitscreen: releasing input: {e}");
}
}
for button in std::mem::take(&mut self.physical_buttons) {
self.extras_mouse(button, false);
}
self.drag_button = None;
self.modifiers = Default::default();
self.left = false;
self.right = false;
self.inventory_mouse_release(false);
self.inventory_mouse_release(true);
self.inventory_pick_release();
self.loom_release();
self.creative_screen.release();
self.ui.options.release();
self.dragging = false;
self.stats_release();
self.world_options_release();
self.advancements_mouse(false, [0, 0]);
if let Some(title) = &mut self.title {
title.release();
title.release_field();
}
self.capture(false);
self.keyboard_mouse = false;
}
}
@@ -476,6 +476,10 @@ impl Split<'_> {
host.settings.horizon.wide_frame(),
// The pack its own game loads, from its profile.
crate::game::shader_packs::picked_pack_draws_flat(&guest.settings),
(
guest.settings.file.int("textureFiltering"),
guest.settings.file.int("maxAnisotropyBit"),
),
)?);
guest.window = Some(window);
guest.world_renderer_ready = true;
+12 -1
View File
@@ -451,7 +451,18 @@ impl Game<'_> {
};
self.audio.play(sound, Some(origin), 1.0, pitch);
}
merchant::Event::Traded(_, offer) => traded.push(*offer),
merchant::Event::Traded(index, offer) => {
// The mods may change the live offer (Bukkit's PlayerTradeEvent#getTrade).
let trader = mob.and_then(|id| {
let m = self.mobs.iter().find(|m| m.id == id)?;
Some((id, super::native_hooks::mob_kind(m)))
});
if let (Some((id, kind)), Some(live)) = (trader, session.offers.get_mut(index))
{
self.native_merchant_offer_traded(id, &kind, live);
}
traded.push(*offer);
}
merchant::Event::TradeSound => {
let sound = if villager {
"minecraft:entity.villager.yes"
+36 -1
View File
@@ -7,6 +7,9 @@
//! them: [`gui`] into the HUD batch with the GUI item lighting, [`WorldSink`]
//! into entity vertices with the level's lighting.
pub mod cit;
mod glint_atlas;
#[cfg(test)]
mod glint_tests;
pub mod gui;
mod special;
@@ -45,6 +48,8 @@ pub struct ItemQuad {
/// The model UVs `GLINT_TEXTURING` scrolls for the glint (vanilla's UV0,
/// or UV3 for a special model).
pub foil_uv: [[f32; 2]; 4],
/// Standard GUI glint uses the vanilla items atlas; CIT keeps sprite-local UVs.
pub gui_glint_uv: Option<[[f32; 2]; 4]>,
pub normal: Vec3,
pub color: u32,
pub foil: Foil,
@@ -422,7 +427,10 @@ fn submit_quads(
FoilType::Standard => Foil::Standard,
FoilType::Special => Foil::Special,
};
let _ = display;
let atlas = renderer
.glint_atlas
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut normals = FaceNormals::new(m);
// Quads of one layer mostly share a sprite: look its rect and its
// translucency up once.
@@ -480,6 +488,12 @@ fn submit_quads(
pos,
uv,
foil_uv: q.uv,
gui_glint_uv: (display == DisplayContext::Gui && foil == Foil::Standard)
.then(|| atlas.get(sprite_name(&q.material.sprite)))
.flatten()
.map(|r| {
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v])
}),
normal: normals.face(q.facing),
color: argb(color),
foil,
@@ -631,6 +645,7 @@ pub struct ItemRenderer {
/// The enabled packs' OptiFine CIT rules and their swapped bakes; `None`
/// without rules.
pub cit: Option<cit::CitModels>,
glint_atlas: Mutex<HashMap<String, Rect>>,
}
/// Every `items/**/*.json` client item definition, by identifier. Through
@@ -663,7 +678,26 @@ fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
out
}
fn glint_padding(filtering: i32, bit: i32) -> usize {
if filtering == 2 {
bit.wrapping_sub(1).clamp(0, 4) as usize
} else {
0
}
}
impl ItemRenderer {
/// Prepare GUI glint after the device exists; propagate packing failures to pack rollback.
pub fn prepare_glint_atlas(&self, maximum: u32, filtering: (i32, i32)) -> crate::Result<()> {
let padding = glint_padding(filtering.0, filtering.1);
let sprites = glint_atlas::load(&self.sprites.root, 1 << padding, maximum)?;
*self
.glint_atlas
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = sprites;
Ok(())
}
/// Load item definitions, item prototypes (`items.json`), every model,
/// the special renderers' entity models and the grass colormap.
pub fn load(root: &Path) -> crate::Result<Self> {
@@ -737,6 +771,7 @@ impl ItemRenderer {
})
.unwrap_or_default();
Ok(Self {
glint_atlas: Mutex::default(),
item_ids,
models,
library,
@@ -0,0 +1,234 @@
//! Vanilla item-atlas coordinates for GUI glint; the HUD's unrelated packing cannot supply them.
use crate::{atlas_sources, hud::Rect};
use horizon_assets::animation::AnimationMetadata;
use std::{collections::HashMap, path::Path};
type Entry = (String, u32, u32);
/// `SpriteSourceList.list` and `SpriteResourceLoader.loadSprite` for the non-mipmapped ITEMS atlas.
pub(super) fn load(
root: &Path,
padding: u32,
maximum: u32,
) -> crate::Result<HashMap<String, Rect>> {
let sources = atlas_sources::atlases(root);
let sources = sources
.get("minecraft:items")
.map(Vec::as_slice)
.unwrap_or(&[]);
let textures = atlas_sources::list_textures(root);
let decode = |name: &str, dir: &str| {
crate::hud::decode(&atlas_sources::texture_path(root, name, dir)).ok()
};
let mut entries = vec![("minecraft:missingno".into(), 16, 16)];
let mut names = Vec::new();
for (id, origin) in atlas_sources::list(sources, &textures) {
names.push(id.clone());
let dimensions = match &origin {
atlas_sources::SpriteOrigin::File(texture) => (|| {
let (w, h, _) = decode(texture, "textures")?;
let path = atlas_sources::texture_path(root, texture, "textures")
.with_extension("png.mcmeta");
let metadata = horizon_core::prepared_files::read_to_string(&path)
.ok()
.map(|text| serde_json::from_str::<serde_json::Value>(&text))
.transpose()
.ok()?;
if let Some(metadata) = &metadata {
metadata.as_object()?;
if let Some(texture) = metadata.get("texture") {
texture_metadata(texture)?;
}
}
let size = match metadata.as_ref().and_then(|v| v.get("animation")) {
Some(animation) => AnimationMetadata::from_json(&animation.to_string())
.ok()?
.frame_size(w as i32, h as i32)
.map(|x| x as u32),
None => [w, h],
};
(size[0] > 0
&& size[1] > 0
&& w.is_multiple_of(size[0])
&& h.is_multiple_of(size[1]))
.then_some((size[0], size[1]))
})(),
// Generated sprites have empty resource metadata, so their whole image is one frame.
_ => atlas_sources::generate(&origin, &decode).map(|(w, h, _)| (w, h)),
};
if let Some((w, h)) = dimensions {
entries.push((id, w, h));
}
}
let mut sprites = stitch(&entries, padding, maximum).ok_or_else(|| {
std::io::Error::other(format!(
"item atlas does not fit texture limit {maximum} with padding {padding}"
))
})?;
// TextureAtlas.getSprite returns missingno for rejected or unplaced source sprites.
if let Some(missing) = sprites.get("missingno").copied() {
for name in names {
sprites
.entry(super::sprite_name(&name).to_owned())
.or_insert(missing);
}
}
Ok(sprites)
}
/// `TextureMetadataSection.CODEC`: invalid fields reject the sprite, even without mipmaps.
fn texture_metadata(section: &serde_json::Value) -> Option<()> {
let fields = section.as_object()?;
for (name, value) in fields.iter().filter(|(_, value)| !value.is_null()) {
let valid = match name.as_str() {
"blur" | "clamp" => value.is_boolean(),
"mipmap_strategy" => matches!(
value.as_str(),
Some("auto" | "mean" | "cutout" | "strict_cutout" | "dark_cutout")
),
"alpha_cutoff_bias" => value.is_number(),
_ => true,
};
if !valid {
return None;
}
}
Some(())
}
#[derive(Default)]
struct Region {
x: u32,
y: u32,
w: u32,
h: u32,
entry: Option<usize>,
children: Vec<Region>,
}
impl Region {
fn new(x: u32, y: u32, w: u32, h: u32) -> Self {
Self {
x,
y,
w,
h,
..Self::default()
}
}
/// `Stitcher.Region.add`: first fitting child, with vanilla's split order and no rotation.
fn add(&mut self, index: usize, w: u32, h: u32) -> bool {
if self.entry.is_some() || w > self.w || h > self.h {
return false;
}
if w == self.w && h == self.h {
self.entry = Some(index);
return true;
}
if self.children.is_empty() {
let (dw, dh) = (self.w - w, self.h - h);
self.children.push(Self::new(self.x, self.y, w, h));
if dw > 0 && dh > 0 {
if self.h.max(dw) >= self.w.max(dh) {
self.children.push(Self::new(self.x, self.y + h, w, dh));
self.children
.push(Self::new(self.x + w, self.y, dw, self.h));
} else {
self.children.push(Self::new(self.x + w, self.y, dw, h));
self.children
.push(Self::new(self.x, self.y + h, self.w, dh));
}
} else if dw == 0 {
self.children.push(Self::new(self.x, self.y + h, w, dh));
} else {
self.children.push(Self::new(self.x + w, self.y, dw, h));
}
}
self.children.iter_mut().any(|r| r.add(index, w, h))
}
fn gather(
&self,
entries: &[Entry],
size: [u32; 2],
padding: u32,
out: &mut HashMap<String, Rect>,
) {
if let Some(i) = self.entry {
let (name, w, h) = &entries[i];
let (x, y) = (self.x + padding, self.y + padding);
out.insert(
super::sprite_name(name).to_owned(),
Rect {
u0: x as f32 / size[0] as f32,
v0: y as f32 / size[1] as f32,
u1: (x + w) as f32 / size[0] as f32,
v1: (y + h) as f32 / size[1] as f32,
w: *w,
h: *h,
},
);
}
for child in &self.children {
child.gather(entries, size, padding, out);
}
}
}
/// `Stitcher.stitch/expand`: mip level zero, padding from the filtering option.
fn stitch(entries: &[Entry], padding: u32, maximum: u32) -> Option<HashMap<String, Rect>> {
let mut order: Vec<_> = entries
.iter()
.enumerate()
.map(|(i, (name, w, h))| {
let (ns, path) = name.split_once(':').unwrap_or(("minecraft", name));
(i, w + padding * 2, h + padding * 2, path, ns)
})
.collect();
order.sort_by(|a, b| {
b.2.cmp(&a.2)
.then(b.1.cmp(&a.1))
.then(a.3.cmp(b.3))
.then(a.4.cmp(b.4))
});
let mut regions: Vec<Region> = Vec::new();
let (mut width, mut height) = (0u32, 0u32);
let pow2 = |x: u32| if x == 0 { 0 } else { x.next_power_of_two() };
for (index, w, h, _, _) in order {
if regions.iter_mut().any(|r| r.add(index, w, h)) {
continue;
}
let (next_w, next_h) = (pow2(width.checked_add(w)?), pow2(height.checked_add(h)?));
let (can_w, can_h) = (next_w <= maximum, next_h <= maximum);
if !can_w && !can_h {
return None;
}
let (grow_w, grow_h) = (
can_w && pow2(width) != next_w,
can_h && pow2(height) != next_h,
);
let horizontal = if grow_w != grow_h {
grow_w
} else {
can_w && pow2(width) <= pow2(height)
};
let mut region = if horizontal {
if height == 0 {
height = next_h;
}
let r = Region::new(width, 0, next_w - width, height);
width = next_w;
r
} else {
let r = Region::new(0, height, width, next_h - height);
height = next_h;
r
};
// Pinned expand ignores add's result for a sprite wider than the device limit.
region.add(index, w, h);
regions.push(region);
}
let mut out = HashMap::new();
for r in regions {
r.gather(entries, [width, height], padding, &mut out);
}
Some(out)
}
@@ -0,0 +1,526 @@
use super::*;
fn png(path: &Path, w: u32, h: u32, color: [u8; 4]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&color.repeat((w * h) as usize))
.unwrap();
writer.finish().unwrap();
}
fn fixture(name: &str) -> PathBuf {
let root =
std::env::temp_dir().join(format!("horizon-glint-5851-{name}-{}", std::process::id()));
let write = |name: &str, data: &[u8]| {
let path = root.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, data).unwrap();
};
write("items.json", b"{}");
write(
"assets/minecraft/atlases/items.json",
br#"{"sources":[
{"type":"directory","source":"item","prefix":"item/"},
{"type":"single","resource":"test:source","sprite":"test:item/enchanted_book"},
{"type":"unstitch","resource":"test:sheet","divisor_x":2,"divisor_y":1,
"regions":[{"sprite":"minecraft:item/compass_00","x":1,"y":0,"width":1,"height":1}]}]}"#,
);
for (name, w, h) in [
("minecraft/item/enchanted_book", 16, 16),
("minecraft/item/book", 8, 16),
("minecraft/item/diamond_sword", 32, 16),
("minecraft/item/clock_00", 16, 64),
("test/source", 16, 16),
("test/sheet", 16, 16),
] {
let (namespace, path) = name.split_once('/').unwrap();
png(
&root.join(format!("assets/{namespace}/textures/{path}.png")),
w,
h,
[255; 4],
);
}
write(
"assets/minecraft/textures/item/clock_00.png.mcmeta",
br#"{"animation":{"width":16,"height":16}}"#,
);
root
}
fn state(sprite: &str, foil: FoilType, display: DisplayContext) -> ItemRenderState {
let material = horizon_assets::model::Material {
sprite: sprite.into(),
force_translucent: false,
};
let baked = BakedItemModel {
quads: vec![horizon_assets::bake::ModelQuad {
positions: [
[-0.5, 0.5, 0.0],
[-0.5, -0.5, 0.0],
[0.5, -0.5, 0.0],
[0.5, 0.5, 0.0],
],
uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
facing: Direction::South,
cull: None,
material: material.clone(),
tint_index: -1,
shade_direction_override: None,
light_emission: 0,
light: [0; 2],
color: None,
ambient_occlusion: None,
render_type: None,
shade: true,
}],
uses_block_light: false,
particle: material,
transforms: Default::default(),
};
ItemRenderState {
display,
layers: vec![LayerRender {
geometry: LayerGeometry::Quads(Arc::new(baked)),
tints: vec![],
foil,
matrix: Mat4::IDENTITY,
particle: sprite.into(),
uses_block_light: false,
extents: Arc::new(vec![]),
}],
oversized_in_gui: false,
animated: false,
cit_glint: None,
hdr_tier: 0,
hdr_light: 0,
}
}
#[test]
fn gui_glint_matches_pinned_vanilla_atlas_input() {
let root = fixture("gui");
let renderer = ItemRenderer::load(&root).unwrap();
renderer.prepare_glint_atlas(16384, (0, 2)).unwrap();
// The real vanilla oracle also checks a rectangular texture, an animation frame,
// an unstitch source and an equal-sized sprite in a second namespace.
for (name, x, y, w, h) in [
("item/enchanted_book", 1, 33, 16, 16),
("item/book", 53, 1, 8, 16),
("item/diamond_sword", 1, 1, 32, 16),
("item/clock_00", 35, 1, 16, 16),
("item/compass_00", 55, 33, 8, 16),
("test:item/enchanted_book", 19, 33, 16, 16),
("missingno", 37, 33, 16, 16),
] {
let r = renderer.glint_atlas.lock().unwrap()[name];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[x, y, x + w, y + h].map(|n| n as f32 / 64.0)
);
}
let sprites = HashMap::from([
(
"item/enchanted_book".into(),
Rect {
u0: 0.2,
v0: 0.3,
u1: 0.4,
v1: 0.5,
w: 16,
h: 16,
},
),
(
"misc/enchanted_glint_item".into(),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 128,
h: 128,
},
),
]);
let render = |foil| {
let mut batch = crate::hud::HudBatch::new([64, 64]);
gui::draw(
&state("minecraft:item/enchanted_book", foil, DisplayContext::Gui),
&renderer,
&sprites,
&NoHost,
&mut batch,
0.0,
0.0,
0.0,
0.0,
0.75,
);
batch
};
let batch = render(FoilType::Standard);
// Real 26.3 SpriteSourceList + SpriteResourceLoader + Stitcher + JOML, over this fixture:
// atlas 64x64, book rectangle (1,33)..(17,49). HUD packing above deliberately differs.
let expected = [
[-0.5931978, 4.084038],
[-0.94049406, 6.0536532],
[1.0291214, 6.4009495],
[1.3764176, 4.431334],
];
let mut seen = [false; 4];
for vertex in batch.vertices.iter().filter(|v| v.uv[0] >= 2.0) {
let index = match vertex.pos {
[0.0, 0.0] => 0,
[0.0, 16.0] => 1,
[16.0, 16.0] => 2,
[16.0, 0.0] => 3,
p => panic!("unexpected corner {p:?}"),
};
for (got, want) in vertex.glint_uv.into_iter().zip(expected[index]) {
assert!((got - want).abs() < 1e-5, "glint {got} != vanilla {want}");
}
seen[index] = true;
}
assert!(seen.into_iter().all(|x| x));
let plain = render(FoilType::None);
let base: Vec<_> = batch
.vertices
.iter()
.filter(|v| v.uv[0] < 2.0)
.map(|v| (v.pos, v.uv, v.color))
.collect();
assert_eq!(
base,
plain
.vertices
.iter()
.map(|v| (v.pos, v.uv, v.color))
.collect::<Vec<_>>()
);
for (display, foil) in [
(DisplayContext::Ground, FoilType::Standard),
(DisplayContext::FirstPersonRightHand, FoilType::Standard),
(DisplayContext::Gui, FoilType::Special),
] {
let mut quads = Vec::new();
state("minecraft:item/enchanted_book", foil, display).submit(
&renderer,
&sprites,
&NoHost,
Mat4::IDENTITY,
&mut quads,
);
assert_eq!(quads.len(), 1);
assert!(quads[0].gui_glint_uv.is_none());
assert_eq!(
quads[0].foil_uv,
[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]
);
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn glint_atlas_boundaries() {
let root = fixture("boundary");
// Pinned vanilla over the same generated fixture; padding 1,2,4,8,16.
for (i, (width, height, x, y)) in [
(64, 64, 1, 33),
(128, 64, 2, 34),
(128, 64, 4, 36),
(128, 64, 40, 40),
(256, 128, 16, 80),
]
.into_iter()
.enumerate()
{
let atlas = glint_atlas::load(&root, 1 << i, 16384).unwrap();
let r = atlas["item/enchanted_book"];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[
x as f32 / width as f32,
y as f32 / height as f32,
(x + 16) as f32 / width as f32,
(y + 16) as f32 / height as f32
]
);
}
png(
&root.join("assets/minecraft/textures/item/wide.png"),
16384,
1,
[255; 4],
);
let atlas = glint_atlas::load(&root, 1, 32768).unwrap();
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[1.0 / 32768.0, 33.0 / 64.0, 17.0 / 32768.0, 49.0 / 64.0]
);
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[1.0 / 64.0, 33.0 / 128.0, 17.0 / 64.0, 49.0 / 128.0]
);
assert_eq!(atlas["item/wide"].u0, atlas["missingno"].u0);
assert!(glint_atlas::load(&root, 1, 16).is_err());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn invalid_and_generated_sprites_match_vanilla() {
let root = fixture("metadata");
for name in ["invalid_size", "invalid_metadata", "not_animated"] {
png(
&root.join(format!("assets/minecraft/textures/item/{name}.png")),
16,
32,
[255; 4],
);
}
for (name, meta) in [
("invalid_size", r#"{"animation":{"width":9,"height":16}}"#),
("invalid_metadata", r#"{"animation":{"frames":"bad"}}"#),
("not_animated", r#"{"texture":{"blur":true}}"#),
] {
fs::write(
root.join(format!("assets/minecraft/textures/item/{name}.png.mcmeta")),
meta,
)
.unwrap();
}
let path = root.join("assets/minecraft/atlases/items.json");
let mut atlas: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
atlas["sources"].as_array_mut().unwrap().extend([
serde_json::json!({"type":"single","resource":"test:absent","sprite":"test:missing_alias"}),
serde_json::json!({"type":"paletted_permutations","textures":["test:trim"],"palette_key":"test:key","permutations":{"red":"test:red"}}),
]);
fs::write(path, atlas.to_string()).unwrap();
for (name, w, h, color) in [
("trim", 8, 16, [255; 4]),
("palettes/key", 1, 1, [255; 4]),
("palettes/red", 1, 1, [255, 0, 0, 255]),
] {
png(
&root.join(format!("assets/test/textures/{name}.png")),
w,
h,
color,
);
}
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
// Real SpriteResourceLoader rejects both invalid animations; TextureAtlas resolves missingno.
for name in ["item/invalid_size", "item/invalid_metadata"] {
let r = atlas[name];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[103.0 / 128.0, 1.0 / 64.0, 119.0 / 128.0, 17.0 / 64.0]
);
}
let r = atlas["item/not_animated"];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[1.0 / 128.0, 1.0 / 64.0, 17.0 / 128.0, 33.0 / 64.0]
);
let r = atlas["test:trim_red"];
assert_eq!(
[r.u0, r.v0, r.u1, r.v1],
[19.0 / 128.0, 37.0 / 64.0, 27.0 / 128.0, 53.0 / 64.0]
);
assert!(!atlas.contains_key("test:missing_alias"));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn invalid_texture_metadata_does_not_move_book_glint() {
let root = fixture("texture-metadata");
let image = root.join("assets/minecraft/textures/item/large.png");
png(&image, 512, 256, [255; 4]);
let metadata = image.with_extension("png.mcmeta");
for invalid in [
r#"{"texture":[]}"#,
r#"{"texture":null}"#,
r#"{"texture":{"mipmap_strategy":"issue5851_invalid"}}"#,
r#"{"texture":{"blur":"true"}}"#,
r#"{"texture":{"clamp":1}}"#,
r#"{"texture":{"alpha_cutoff_bias":"0.5"}}"#,
] {
fs::write(&metadata, invalid).unwrap();
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
let rejected = atlas["item/large"];
let missing = atlas["missingno"];
assert_eq!(
[rejected.u0, rejected.v0, rejected.u1, rejected.v1],
[missing.u0, missing.v0, missing.u1, missing.v1],
"{invalid}"
);
// Existing real-vanilla fixture: rejecting the extra image leaves its book unchanged.
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[1.0 / 64.0, 33.0 / 64.0, 17.0 / 64.0, 49.0 / 64.0],
"{invalid}"
);
}
for strategy in ["auto", "mean", "cutout", "strict_cutout", "dark_cutout"] {
fs::write(
&metadata,
serde_json::json!({"texture":{"blur":true,"clamp":false,
"mipmap_strategy":strategy,"alpha_cutoff_bias":0.5}})
.to_string(),
)
.unwrap();
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
assert_eq!((atlas["item/large"].w, atlas["item/large"].h), (512, 256));
}
fs::remove_dir_all(root).unwrap();
}
#[test]
fn glint_atlas_reads_prepared_images_and_metadata_together() {
let root = fixture("prepared-metadata");
let image = root.join("assets/minecraft/textures/item/large.png");
png(&image, 512, 256, [255; 4]);
let metadata = image.with_extension("png.mcmeta");
fs::write(&metadata, r#"{"texture":[]}"#).unwrap();
fs::write(root.join("horizon-resources.json"), "{}").unwrap();
horizon_core::prepared_files::write(&root).unwrap();
// The renderer reads the prepared snapshot, even if loose files now differ.
fs::write(&metadata, r#"{"texture":{"blur":true}}"#).unwrap();
png(
&root.join("assets/minecraft/textures/item/enchanted_book.png"),
256,
256,
[0, 255, 0, 255],
);
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
assert_eq!(atlas["item/large"].u0, atlas["missingno"].u0);
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[1.0 / 64.0, 33.0 / 64.0, 17.0 / 64.0, 49.0 / 64.0]
);
fs::remove_dir_all(root).unwrap();
}
#[test]
#[ignore = "requires the player's prepared pinned 26.3 resources in HORIZON_TEST_RESOURCES"]
fn prepared_item_atlas_matches_pinned_vanilla() {
let root =
PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources"));
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
assert_eq!(atlas.len(), 896);
let book = atlas["item/enchanted_book"];
assert_eq!(
[book.u0, book.v0, book.u1, book.v1],
[437.0 / 1024.0, 163.0 / 512.0, 453.0 / 1024.0, 179.0 / 512.0]
);
let glint = Glint {
millis: 0.0,
speed: 0.0,
alpha: 0.75,
scale: 8.0,
};
let uv = glint.uv([book.u0, book.v0]);
for (got, want) in uv.into_iter().zip([2.919935, 3.101028]) {
assert!((got - want).abs() < 1e-5);
}
}
#[test]
#[ignore = "requires an independently generated pinned vanilla atlas JSON and matching resources"]
fn external_item_atlas_oracle() {
let root = PathBuf::from(std::env::var_os("HORIZON_GLINT_ROOT").expect("resource root"));
let expected: Value = serde_json::from_str(
&fs::read_to_string(
std::env::var_os("HORIZON_GLINT_EXPECTED").expect("vanilla atlas JSON"),
)
.unwrap(),
)
.unwrap();
let bit = std::env::var("HORIZON_GLINT_ANISO_BITS")
.unwrap()
.parse()
.unwrap();
let sprites = glint_atlas::load(
&root,
1 << glint_padding(if bit == 0 { 0 } else { 2 }, bit),
32768,
)
.unwrap();
for (name, value) in expected["sprites"].as_object().unwrap() {
let actual = sprites[sprite_name(name)];
for (got, key) in [actual.u0, actual.v0, actual.u1, actual.v1]
.into_iter()
.zip(["u0", "v0", "u1", "v1"])
{
let want = value[key].as_f64().unwrap() as f32;
assert!(
(got - want).abs() < 1e-7,
"{name} {key}: {got} != vanilla {want}"
);
}
}
if let Some(rejected) = expected["rejected"].as_array() {
let missing = sprites["missingno"];
for name in rejected {
let actual = sprites[sprite_name(name.as_str().unwrap())];
assert_eq!(
[actual.u0, actual.v0, actual.u1, actual.v1],
[missing.u0, missing.v0, missing.u1, missing.v1]
);
}
let renderer = ItemRenderer::load(&root).unwrap();
renderer
.prepare_glint_atlas(32768, (if bit == 0 { 0 } else { 2 }, bit))
.unwrap();
let hud_sprites = HashMap::from([
("item/enchanted_book".into(), missing),
("misc/enchanted_glint_item".into(), missing),
]);
let mut batch = crate::hud::HudBatch::new([64, 64]);
gui::draw(
&state(
"minecraft:item/enchanted_book",
FoilType::Standard,
DisplayContext::Gui,
),
&renderer,
&hud_sprites,
&NoHost,
&mut batch,
0.0,
0.0,
0.0,
0.0,
0.75,
);
let want = &expected["sprites"]["minecraft:item/enchanted_book"]["glint_uv_clock0"];
let mut seen = [false; 4];
for vertex in batch.vertices.iter().filter(|v| v.uv[0] >= 2.0) {
let index = match vertex.pos {
[0.0, 0.0] => 0,
[0.0, 16.0] => 3,
[16.0, 16.0] => 2,
[16.0, 0.0] => 1,
p => panic!("unexpected corner {p:?}"),
};
for (got, value) in vertex
.glint_uv
.into_iter()
.zip(want[index].as_array().unwrap())
{
assert!((got - value.as_f64().unwrap() as f32).abs() < 1e-5);
}
seen[index] = true;
}
assert!(seen.into_iter().all(|v| v));
}
}
+10 -2
View File
@@ -47,6 +47,7 @@ struct Point {
p: Vec2,
uv: [f32; 2],
glint_uv: [f32; 2],
cit_uv: [f32; 2],
}
fn lerp(a: Point, b: Point, t: f32) -> Point {
Point {
@@ -55,6 +56,7 @@ fn lerp(a: Point, b: Point, t: f32) -> Point {
a.uv[0] + (b.uv[0] - a.uv[0]) * t,
a.uv[1] + (b.uv[1] - a.uv[1]) * t,
],
cit_uv: std::array::from_fn(|k| a.cit_uv[k] + (b.cit_uv[k] - a.cit_uv[k]) * t),
glint_uv: [
a.glint_uv[0] + (b.glint_uv[0] - a.glint_uv[0]) * t,
a.glint_uv[1] + (b.glint_uv[1] - a.glint_uv[1]) * t,
@@ -155,7 +157,7 @@ fn emit_cit(batch: &mut HudBatch, fans: &[Vec<Point>], cit: CitDraw) {
}
let glint = &cit.rules[layer.rule].glint;
let color = vertex_color(slot, intensity);
let uv = |v: Point| layer_uv(v.glint_uv, cit.millis, glint.speed, glint.rotation, 8.0);
let uv = |v: Point| layer_uv(v.cit_uv, cit.millis, glint.speed, glint.rotation, 8.0);
let start = batch.vertices.len();
for fan in fans {
for k in 1..fan.len().saturating_sub(1) {
@@ -405,6 +407,7 @@ fn draw_quads_with(
p: v.pos.truncate(),
uv: v.uv,
glint_uv: v.glint_uv,
cit_uv: v.cit_uv,
})
.collect();
let color = lit_color(q, lights);
@@ -585,6 +588,7 @@ mod tests {
pos: [(0.0, y0), (0.0, y1), (8.0, y1), (8.0, y0)].map(|(x, y)| Vec3::new(x, y, z(y))),
uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
foil_uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
gui_glint_uv: None,
normal: Vec3::Z,
color,
foil: Foil::None,
@@ -725,8 +729,10 @@ mod tests {
};
let mut batch = HudBatch::new([320, 240]);
batch.glint_alpha = 0.5;
let mut quad = face(0.0, 8.0, |_| 0.0, 0xFFFF_FFFF);
quad.gui_glint_uv = Some([[42.0, 42.0]; 4]);
draw_quads_with(
&[face(0.0, 8.0, |_| 0.0, 0xFFFF_FFFF)],
&[quad],
(Vec3::Z, Vec3::Z),
&mut batch,
[0.0, 0.0, 16.0, 16.0],
@@ -742,6 +748,7 @@ mod tests {
for v in &batch.vertices[12..] {
// Slot 1 in red; full intensity at half the glint strength in alpha.
assert_eq!(v.color.to_le_bytes(), [1, 0, 0, 128]);
assert_eq!(v.glint_uv, v.pos, "CIT retains the sprite-local UVs");
}
}
@@ -762,6 +769,7 @@ mod tests {
p: Vec2::new(x, y),
uv: [x, y],
glint_uv: [0.0, 0.0],
cit_uv: [0.0, 0.0],
};
let fan = clip_triangle(
[p(-4.0, 0.0), p(8.0, 0.0), p(8.0, 8.0)],
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff. Show more