mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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:
commit
4682b7838c
285 files changed
+8379
-1751
No files matched your search
+2
-2
@@ -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>
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(§ion, 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(¶ms, &words, &record);
|
||||
let kept = keeps(¶ms, &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(¶ms, &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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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])),
|
||||
]),
|
||||
®istry,
|
||||
)
|
||||
.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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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, ¢res) {
|
||||
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, ¢res, 10.0) {
|
||||
self.pad_pointer_to(event_loop, centres[i]);
|
||||
if let Some(i) = snap::settle(start, self.cursor, ¢res, 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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user