Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 061bd8df9a Container screens: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:01:19 +01:00
Horizon agentandClaude Opus 5.5 9c8c87aec0 Container screens: container_open borrows the game for its own lifetime; no unreachable role arm
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:52:37 +01:00
Horizon agentandClaude Opus 5.5 eedfce0c3a Container screens: container_key gets the hovered slot, as the other inputs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:44:12 +01:00
Horizon agentandClaude Opus 5.5 04c786c254 Container screens: the hooks, and the engine's Container over the open menu (layer plan M5)
Hooks gets container_press/drag/release/wheel/key/draw; the engine builds a
Container over the open menu screen only while a hook is registered, and
dispatches at the Mouse Tweaks' and sorting's call sites, after them. Mouse
Tweaks and sorting move onto the hooks next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:37:46 +01:00
Horizon agent 8566c44761 Merge branch 'layers/extras-tweaks' into layers/container-api 2026-11-09 19:30:26 +01:00
Horizon agentandClaude Opus 5.5 67e862d81e Look: crates change their own fields (update), so several can share it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:24:18 +01:00
Horizon agentandClaude Opus 5.5 f26c385a1f Look: clippy's struct update in the seat test; rustfmt; gfx.rs keeps its line count
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:12:47 +01:00
Horizon agentandClaude Opus 5.5 3d29a7cadb PvP & HUD: the engine reads it only through a Look of plain data (layer plan M5)
horizon_hooks::look: what the engine draws differently (legacy pose,
attack indicator, sounds and particles, outline/hurt/glint colours,
shield, totem, fire, fluids, chat extras, effect icons, view time and
weather), vanilla's by default, set per splitscreen seat. PvP & HUD's
glue sets it each frame from the options (Pvp::look); the engine reads
it only in game/look.rs. PvP & HUD's crate will set it from its own
settings when it moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:06:08 +01:00
Horizon agent de06052e87 Merge remote-tracking branch 'origin/layers/seat' into layers/reconnect-hooks 2026-11-09 18:48:45 +01:00
Horizon agent 547b1d4959 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 18:40:43 +01:00
Horizon agentandClaude Opus 5.5 e2918de5aa Seat: a splitscreen guest's profile loads into its own seat
Split::enter loaded a guest's profile before its turn began, so the
crates' settings stores read the guest's lines into P1's seat. The load
now runs on the guest's seat; a new profile is saved on P1's seat (the
stores write P1's values, as the rest of the profile copies P1's) and
its stores are read back into the guest's (Settings::load_stores).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:35 +01:00
Horizon agentandClaude Opus 5.5 fe0a4d80bf Mouse Tweaks: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:22:34 +01:00
Horizon agentandClaude Opus 5.5 adc454cfc9 Mouse Tweaks and sorting move to game/extras/, one module each (layer plan M5)
A pure move: game/tweaks.rs becomes game/extras/mouse_tweaks.rs, and
the sort buttons and key leave it for game/extras/sorting.rs. Paths,
visibility (pub(in crate::game)) and imports only; game.rs loses its
mod line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:19:15 +01:00
Horizon agent 2cdecbf069 Merge remote-tracking branch 'origin/pr-13893' into layers/extras-tweaks 2026-11-09 18:19:15 +01:00
Horizon agentandClaude Opus 5.5 ce784e0c66 Reconnect hooks: one tracker and setting per splitscreen seat
Splitscreen players share the process's hooks; each kept its own
tracker in its Game before, so the hook keys them by horizon_hooks::seat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:07:45 +01:00
Horizon agent 55b52aec89 Merge remote-tracking branch 'origin/layers/seat' into layers/reconnect-hooks 2026-11-09 18:06:55 +01:00
Horizon agentandClaude Opus 5.5 c415e8a762 Reconnect hooks: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:06:53 +01:00
Horizon agentandClaude Opus 5.5 e869269ab6 Auto-reconnect runs on engine connection hooks; the engine keeps the last server (layer plan M5)
horizon-hooks gains a connection's lifecycle: connecting, joined, left
and ended, which may return a Reconnect the engine shows on the
Disconnected screen and runs when due (first plan wins). The engine
keeps the server it connected to last itself (last_server.rs), which
splitscreen, the friends presence, pad crafting and PvP read, instead
of auto-reconnect's tracker. The reconnect policy is a Hooks
implementation registered at startup with horizon-extras; only its
setting is still handed over from the engine's extras, until the page
API lets it leave.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:44:57 +01:00
Horizon agent bd90908a72 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 17:44:40 +01:00
Horizon agent 6297f569a2 Merge remote-tracking branch 'origin/layers/settings-store' into layers/reconnect-hooks 2026-11-09 17:40:59 +01:00
Horizon agentandClaude Opus 5.5 a8d445f56c Hooks: seat(), the splitscreen player whose turn it is
Splitscreen players share one process and thread, so a crate's statics
are shared by them. The engine now says whose turn it is
(horizon_hooks::seat(), re-exported in horizon_platform::hooks; 0 for
P1), set where split.rs switches turns and while a joining player's game
starts, so settings stores, pages and HUD layers can keep per-player
state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:09 +01:00
Horizon agentandClaude Opus 5.5 4eee03985c The engine reads PvP & HUD's options through game/extras/pvp.rs (layer plan M5)
Every read of settings.extras.pvp outside the PvP module and its HUD
editor (glint, attack indicator, legacy look and rules, sound and
particle filters, shield and totem size, fire height, clear fluids,
chat search and copy) now calls one accessor in game/extras/pvp.rs.
Same values, read at the same places: each accessor becomes one engine
knob or hook when PvP & HUD leaves the engine.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:10:24 +01:00
Horizon agentandClaude Opus 5.5 5a589fb0f0 The PvP & HUD options' game glue moves to game/extras/pvp.rs (layer plan M5)
A pure move under game/extras/, where each Horizon extra's game glue
gathers: paths and visibility only (pub(in crate::game)). game.rs
loses its mod line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:21:29 +01:00
Horizon agentandClaude Opus 5.5 a9ce60dde5 Auto-reconnect's game glue moves to game/extras/reconnect.rs (layer plan M5)
A pure move: the connection's start, join and end, the countdown and the
retry leave game/extras.rs for a child module of their own, so the extra
leaves the engine in one move once the seam lands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:18 +01:00
52 changed files with 1647 additions and 490 deletions

No files matched your search

+1 -1
View File
@@ -186,7 +186,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs` + `extras/` (each extra's glue: `mouse_tweaks.rs`, `sorting.rs`, `pvp.rs`, `reconnect.rs`), `options.rs` |
## How `Game` is organised
@@ -362,7 +362,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
look: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -430,7 +430,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
look: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
+5 -5
View File
@@ -1001,8 +1001,8 @@ pub struct Frame<'a> {
pub outline_after_terrain: bool,
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
pub outline_high_contrast: bool,
/// The PvP & HUD page's outline and hit colour (`pvp::Frame`; default: vanilla's).
pub pvp: crate::pvp::Frame,
/// The outline, hurt overlay and glint colours a crate above set (`horizon_hooks::look`).
pub look: horizon_hooks::look::Frame,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -8506,9 +8506,9 @@ impl Renderer {
colored_count: [
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
// PvP & HUD's hit colour: entity.wgsl's hurt texel, 0 for vanilla's.
frame.pvp.hurt_texel,
frame.look.hurt_texel,
// PvP & HUD's glint colour: entity.wgsl's `tinted_glint`, 0 for vanilla's.
frame.pvp.glint,
frame.look.glint,
0,
],
emissive_layers: {
@@ -9495,7 +9495,7 @@ impl Renderer {
}
};
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
let (custom, scale) = frame.look.outline.map_or((None, 1.0), |o| (Some(o.0), o.1));
if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0 * scale, lines);
stroke(custom.unwrap_or(0xff57_ffe1), width * scale, lines);
@@ -524,7 +524,7 @@ impl Scene {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
look: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -602,7 +602,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
look: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -440,7 +440,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
look: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -409,7 +409,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
look: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -2664,7 +2664,7 @@ pub(super) mod initial_history_tests {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
look: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
+57 -87
View File
@@ -4,6 +4,7 @@
//! Horizon Settings > PvP & HUD. Every option is off by default, so the game looks like
//! vanilla until the player changes one, and none of them shows anything vanilla doesn't
//! already tell the player.
use horizon_hooks::look::Look;
/// Colours the PvP options pick from. `Inverted` is vanilla's crosshair blend: the colour
/// under it, inverted.
@@ -263,15 +264,6 @@ impl ViewWeather {
Self::ALL.into_iter().find(|c| c.name() == s)
}
}
/// The day time this player sees with `fixed` set: the same day (the moon's phase follows the
/// world's), at that time.
pub fn view_day_time(world: i64, fixed: Option<ViewTime>) -> i64 {
match fixed {
Some(t) => world.div_euclid(24000) * 24000 + t.day_time(),
None => world,
}
}
/// The crosshair's look. Its defaults draw vanilla's: a 15-pixel cross, one pixel thick,
/// inverting what is under it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -528,24 +520,36 @@ impl Hit {
}
}
/// What the renderer reads of these options each frame (`FrameInputs::pvp`); the default is
/// vanilla's look.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Frame {
/// The block outline's stroke ([`Outline::stroke`]).
pub outline: Option<(u32, f32)>,
/// The hurt overlay texel ([`Hit::texel`]), 0 for vanilla's.
pub hurt_texel: u32,
/// The glint's colour ([`Tweaks::glint_texel`]), 0 for vanilla's.
pub glint: u32,
}
impl Pvp {
pub fn frame(&self) -> Frame {
Frame {
outline: self.outline.stroke(),
hurt_texel: self.hit.texel(),
glint: self.tweaks.glint_texel(),
}
/// What the engine reads of these options (`horizon_hooks::look`): all it draws differently.
/// Sets PvP & HUD's fields of `look`, the others' stay.
pub fn apply(&self, look: &mut Look) {
let (c, t) = (self.combat, self.tweaks);
look.legacy_pose = c.legacy;
look.hide_attack_indicator = c.legacy && !c.indicator;
look.mute_attack_sounds = c.legacy && c.sounds;
look.hide_attack_particles = c.legacy;
look.frame.outline = self.outline.stroke();
look.frame.hurt_texel = self.hit.texel();
look.frame.glint = t.glint_texel();
look.shield_size = t.shield_size;
look.totem_scale = f32::from(t.totem_size) / 100.0;
look.fire_lower = f32::from(t.fire_lower) / 100.0;
look.clear_lava = t.clear_lava;
look.clear_water = t.clear_water;
look.chat.timestamps = self.chat.timestamps;
look.chat.compact = self.chat.compact;
look.chat.copy = self.chat.copy;
look.chat.search = self.chat.search;
look.hide_effect_icons = self.hud.effects;
look.time_of_day = t.time.map(ViewTime::day_time);
look.weather = t.weather.map(ViewWeather::levels);
}
/// [`Pvp::apply`] on vanilla's look.
pub fn look(&self) -> Look {
let mut look = Look::default();
self.apply(&mut look);
look
}
}
@@ -741,15 +745,6 @@ pub struct ChatOptions {
/// Ctrl+F in the chat searches the chat's messages.
pub search: bool,
}
impl ChatOptions {
/// What `chat::Chat` lays its lines out with.
pub fn qol(&self) -> crate::chat::ChatQol {
crate::chat::ChatQol {
timestamps: self.timestamps,
compact: self.compact,
}
}
}
/// Combat feel (`docs/pvp.md`): how fights look. Legacy draws them as 1.8.9 did; what the
/// game sends to a server never changes, so a vanilla server and its anticheat see 26.3.
@@ -766,21 +761,6 @@ pub struct Combat {
/// (`horizon_client::legacy_combat`). Never used as a client of a server.
pub rules: bool,
}
impl Combat {
/// Whether this client leaves out sound `name` a server sent: under Legacy with its
/// sounds, the `entity.player.attack.*` family (strong, weak, crit, sweep, knockback,
/// nodamage), which 1.9 brought with the attack cooldown.
pub fn mutes_sound(&self, name: &str) -> bool {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
self.legacy && self.sounds && name.starts_with("entity.player.attack.")
}
/// Whether this client leaves out particles of `ty` a server sent: under Legacy, the
/// sweep and the damage hearts, which 1.9 brought (1.8.9's hits show crits only).
pub fn hides_particle(&self, ty: crate::particles::ParticleType) -> bool {
use crate::particles::ParticleType as T;
self.legacy && matches!(ty, T::SweepAttack | T::DamageIndicator)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Pvp {
@@ -1707,18 +1687,6 @@ mod tests {
assert_eq!(fresh.extras.pvp.tweaks.fire_lower, PRESET_FIRE_LOWER);
}
#[test]
fn a_fixed_time_keeps_the_worlds_day() {
// Day 9 at 5000: the moon's phase (the day number) stays, the time of day is the chosen.
let world = 9 * 24000 + 5000;
assert_eq!(view_day_time(world, None), world);
assert_eq!(
view_day_time(world, Some(ViewTime::Midnight)),
9 * 24000 + 18000
);
assert_eq!(view_day_time(-100, Some(ViewTime::Noon)), -24000 + 6000);
}
#[test]
fn the_glint_colour_reaches_the_shaders_as_rgb_bytes() {
let mut t = Tweaks::default();
@@ -1805,31 +1773,33 @@ mod tests {
}
}
/// Legacy leaves out what 1.9 added to a hit: its sounds only with the sounds option, the
/// sweep and hearts always; the indicator stays when kept. Modern changes nothing.
#[test]
fn legacy_leaves_out_what_1_9_added_to_a_hit() {
use crate::particles::ParticleType as T;
let modern = Combat::default();
let legacy = Combat {
legacy: true,
..Combat::default()
};
let sounds = Combat {
sounds: true,
..legacy
};
for name in [
"minecraft:entity.player.attack.strong",
"entity.player.attack.sweep",
"minecraft:entity.player.attack.nodamage",
] {
assert!(sounds.mutes_sound(name), "{name}");
assert!(!legacy.mutes_sound(name), "only with the sounds option");
assert!(!modern.mutes_sound(name));
}
// 1.8.9 had the hurt sound: it stays.
assert!(!sounds.mutes_sound("minecraft:entity.player.hurt"));
assert!(legacy.hides_particle(T::SweepAttack) && legacy.hides_particle(T::DamageIndicator));
assert!(!legacy.hides_particle(T::Crit) && !legacy.hides_particle(T::EnchantedHit));
assert!(!modern.hides_particle(T::SweepAttack));
fn combat_feel_reaches_the_look() {
let mut pvp = Pvp::default();
assert_eq!(pvp.look(), Look::default(), "vanilla's look");
let strong = "minecraft:entity.player.attack.strong";
pvp.combat.legacy = true;
let look = pvp.look();
assert!(look.legacy_pose && look.hide_attack_particles && look.hide_attack_indicator);
assert!(!look.mutes_sound(strong), "only with the sounds option");
pvp.combat.sounds = true;
pvp.combat.indicator = true;
let look = pvp.look();
assert!(look.mutes_sound(strong) && !look.hide_attack_indicator);
}
#[test]
fn view_time_weather_and_sizes_reach_the_look() {
let mut pvp = Pvp::default();
pvp.tweaks.time = Some(ViewTime::Midnight);
pvp.tweaks.weather = Some(ViewWeather::Thunder);
pvp.tweaks.totem_size = 50;
pvp.tweaks.fire_lower = 40;
let look = pvp.look();
assert_eq!(look.day_time(9 * 24000 + 5000), 9 * 24000 + 18000);
assert_eq!(look.weather, Some((1.0, 1.0)));
assert_eq!((look.totem_scale, look.fire_lower), (0.5, 0.4));
}
}
@@ -337,6 +337,13 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
+1
View File
@@ -87,6 +87,7 @@ pub mod item_tooltip;
pub mod join_seq;
pub mod lan;
pub mod landing;
pub mod last_server;
pub mod legacy_combat;
pub mod locator_bar;
pub mod lod_net;
+5
View File
@@ -21,6 +21,11 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Auto-reconnect, a Horizon extra still in the engine crates, runs on the connection hooks as
// the crates above do (horizon_client::reconnect; docs/layers.md, plan M5).
if cfg!(feature = "horizon-extras") {
let _ = horizon_hooks::register(&horizon_client::reconnect::HOOKS);
}
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
+46
View File
@@ -189,6 +189,52 @@ impl hud::Canvas for Canvas<'_> {
}
}
/// Draws on an open container screen (`horizon_hooks::containers`): `draw` gets a canvas whose
/// (0, 0) is the panel's top left corner, at `origin` on the screen. It has no corners: `place`
/// answers `None`.
pub fn draw_on_panel(
batch: &mut HudBatch,
atlas: &HudAtlas,
origin: (f32, f32),
draw: impl FnOnce(&mut dyn hud::Canvas),
) {
draw(&mut Panel {
batch,
atlas,
origin,
});
}
struct Panel<'a> {
batch: &'a mut HudBatch,
atlas: &'a HudAtlas,
origin: (f32, f32),
}
impl hud::Canvas for Panel<'_> {
fn size(&self) -> (f32, f32) {
(self.batch.width, self.batch.height)
}
fn tr(&self, key: &str) -> String {
crate::lang::get(key)
}
fn text_width(&self, text: &str) -> f32 {
HudBatch::text_width(self.atlas, text)
}
fn text(&mut self, x: f32, y: f32, text: &str, argb: u32, shadow: bool) {
let (x, y) = (self.origin.0 + x, self.origin.1 + y);
self.batch
.text(self.atlas, text, x, y, argb_color(argb), shadow);
}
fn fill(&mut self, x: f32, y: f32, w: f32, h: f32, argb: u32) {
let (x, y) = (self.origin.0 + x, self.origin.1 + y);
self.batch.fill(self.atlas, x, y, w, h, argb_color(argb));
}
fn place(&mut self, _: hud::Corner, _: f32, _: f32) -> Option<(f32, f32)> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
+18 -21
View File
@@ -237,6 +237,7 @@ mod llamas;
mod loading_screen;
mod loading_window;
mod locator_bar;
mod look;
mod loom;
mod mace;
mod machines;
@@ -383,7 +384,6 @@ mod potions;
mod powder_snow;
mod prediction;
mod pumpkin;
mod pvp;
mod quick_actions;
mod raids;
mod random_textures;
@@ -467,7 +467,6 @@ mod trial_spawner;
mod trial_visuals;
mod trident;
mod tutorial;
mod tweaks;
mod ui_control;
mod use_hand;
mod using_item;
@@ -2222,7 +2221,7 @@ struct Game<'a> {
/// `AbstractContainerScreen`'s quick craft and skip-release state.
inv_clicks: horizon_client::container_screen::Clicks,
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
tweaks: tweaks::Tweaks,
tweaks: extras::mouse_tweaks::Tweaks,
/// The wheel and hovered slot of `BundleMouseActions`.
bundle_mouse: bundles::BundleMouse,
/// The last inventory click, for the double click that gathers a stack.
@@ -2811,10 +2810,9 @@ struct Game<'a> {
pending_bench: Option<(Instant, std::sync::mpsc::Sender<String>)>,
/// Recent frame times for the performance overlay.
frame_graph: horizon_client::perf_overlay::FrameGraph,
/// Horizon's extras (minimap, auto-reconnect; mods' counters): `game/extras.rs`.
/// Horizon's extras (mods' minimap and counters; PvP & HUD's): `game/extras.rs`.
extras: extras::ExtrasState,
/// The PvP & HUD options' state (`game/pvp.rs`).
pvp: pvp::PvpState,
pvp: extras::pvp::PvpState,
/// Horizon mods (`game/mods.rs`), none running unless turned on.
mods: mods::ModsState,
/// Every item and recipe, for mods (`game/native_recipes.rs`), made once.
@@ -2879,9 +2877,9 @@ struct Game<'a> {
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
/// service's presence status.
realm_session: bool,
/// The multiplayer server connected to last, and whether it is a Realm; the connection's
/// lifecycle reaches the hooks through it (`last_server.rs`).
last_server: horizon_client::last_server::LastServer,
/// Active packet replay session (game/studio.rs).
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
@@ -5532,7 +5530,7 @@ impl Game<'_> {
}
} else if self.remote.is_some() {
// `getCurrentServer().isRealm()`: the connection came from Realms.
if self.realm_session {
if self.last_server.realm {
PresenceStatus::PlayingRealms
} else {
PresenceStatus::PlayingServer
@@ -5875,7 +5873,7 @@ impl Game<'_> {
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
self.pad.guide_shown.clear();
let mut batch = HudBatch::new(size);
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
batch.glint_colour = self.glint_texel();
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
batch.game_time = ((self.world.game_time.rem_euclid(24000)) as f32
+ self.clock.fraction() as f32)
@@ -5988,8 +5986,7 @@ impl Game<'_> {
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
// extractVehicleHealth and the experience bar, laid out by hud_layout.
// Combat feel Legacy hides the attack indicator unless the player keeps it.
let combat = self.settings.extras.pvp.combat;
let indicator = if combat.legacy && !combat.indicator {
let indicator = if self.attack_indicator_hidden() {
0
} else {
self.settings.attack_indicator
@@ -6053,7 +6050,7 @@ impl Game<'_> {
});
self.gui_layer("minecraft:effects", &mut batch, |g, batch| {
// The PvP & HUD effects widget takes the icons' place.
if !g.pvp_effects_shown() {
if !g.effect_icons_hidden() {
g.draw_effect_icons(batch);
}
});
@@ -6488,7 +6485,7 @@ impl Game<'_> {
self.draw_hovered_slot_highlight(batch, false, ox, oy);
self.draw_inventory_slots(batch, ox, oy);
self.draw_hovered_slot_highlight(batch, true, ox, oy);
self.draw_sort_buttons(batch, ox, oy);
self.draw_container_extras(batch, ox, oy);
self.draw_recipe_book(batch);
let effect_tip = if self.container.is_none() && self.craft_size == 2 && !self.mount_active()
{
@@ -9898,7 +9895,7 @@ impl Game<'_> {
outline,
outline_after_terrain,
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
pvp: self.settings.extras.pvp.frame(),
look: self.look_frame(),
debug_lines: &debug_lines,
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
@@ -10709,7 +10706,7 @@ impl Game<'_> {
if self.screen == Screen::Inventory
&& d != 0.0
&& !bundle
&& !self.tweak_scroll(d, self.inventory_target().0)
&& !self.container_scroll(d)
{
self.station_scroll(d);
}
@@ -10957,7 +10954,7 @@ impl Game<'_> {
return Ok(());
}
if pressed
&& (self.sort_mouse_press(button)
&& (self.container_mouse_press(button)
|| self.hud_editor_key_press(&horizon_client::keybinds::mouse_name(
button,
)))
@@ -11499,7 +11496,7 @@ impl Game<'_> {
}
}
self.extras_frame(elapsed);
self.extras_retry()?;
self.reconnect_when_due()?;
self.apply_mouse_look();
self.pad_look(elapsed.as_secs_f32());
self.advance_flight();
@@ -11610,7 +11607,7 @@ impl Game<'_> {
self.settings.simulation_distance,
self.settings.entity_distance,
);
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
server.set_legacy_rules(self.pvp_legacy_rules());
}
self.clock.end_frame(self.pause);
let mut consumed = false;
@@ -12210,7 +12207,7 @@ impl<'a> Game<'a> {
persist: Default::default(),
entity_attachments: Default::default(),
remote: None,
realm_session: false,
last_server: Default::default(),
studio_replay: None,
studio_freecam: Default::default(),
studio_timeline: Default::default(),
+4 -4
View File
@@ -1278,7 +1278,7 @@ impl Game<'_> {
let ctrl = self.has_control_down_with_quirk();
let shift = self.has_shift_down();
// PvP & HUD's chat search: Ctrl+F starts and stops it, Enter stops it (sends nothing).
if self.settings.extras.pvp.chat.search
if self.chat_look().search
&& match key {
KeyCode::KeyF => ctrl,
KeyCode::Enter | KeyCode::NumpadEnter => self.pvp.chat_search,
@@ -1487,7 +1487,7 @@ impl Game<'_> {
cursor.1 as f64,
);
// PvP & HUD's copy on click: a message where the click has nothing else to do.
if self.settings.extras.pvp.chat.copy
if self.chat_look().copy
&& !self.has_shift_down()
&& style.as_ref().is_none_or(|s| s.click.is_none())
&& let Some(message) =
@@ -1665,8 +1665,8 @@ impl Game<'_> {
/// Chat lines over the HUD (focused while the chat screen is open).
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
let focused = self.screen == Screen::Chat;
let options = self.settings.extras.pvp.chat;
self.ui.chat.set_qol(&self.gui.atlas, options.qol());
let options = self.chat_look();
self.ui.chat.set_qol(&self.gui.atlas, self.chat_qol());
// A search ends with its option; a chat closed some other way ends it in keep_chat_draft.
if self.pvp.chat_search && self.ui.chat_screen.is_none() {
// The chat went without closing (leaving the world): nothing to give back.
+1 -1
View File
@@ -1249,7 +1249,7 @@ impl Game<'_> {
self.sync_ops(&lists);
let anticheat = load_anticheat();
let chat_filter = load_chat_filter();
self.settings.extras.pvp.combat.rules = load_combat();
self.set_pvp_legacy_rules(load_combat());
// Horizon's far ring (docs/hosting.md): server.toml's `max_view_distance`.
let far = props
.raw
+11 -74
View File
@@ -2,15 +2,21 @@
//! (`native_minimap`: its tiles, image and HUD) and zoom (`native_zoom`) on the camera; clicks
//! and frame times for mods (`native_clicks`, `native_frame_rate`).
//! Each hook does nothing while its extra is off and no mod uses it.
//!
//! Each Horizon extra's game glue is a child module here, so it leaves the engine in one move
//! (docs/layers.md, plan M5): `reconnect` (auto-reconnect), `pvp` (the PvP & HUD options),
//! `mouse_tweaks` and `sorting` (the inventory extras).
pub(in crate::game) mod mouse_tweaks;
pub(in crate::game) mod pvp;
mod reconnect;
mod sorting;
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
use horizon_client::{
extras::{Corner, Cps, FpsCounter, fov_target},
extras_hud::{self, Corners},
hud::{HudBatch, MINIMAP_SPRITE},
minimap::{self, Layer, Minimap, View},
multiplayer,
reconnect::{Retry, Tracker},
title::Mode,
};
use std::time::Duration;
use winit::event::MouseButton;
@@ -24,8 +30,6 @@ pub(super) struct ExtrasState {
minimap: Minimap,
/// The dimension the minimap's tiles belong to.
minimap_dim: Option<Dim>,
/// Auto-reconnect's attempts at the current server.
reconnect: Tracker,
}
/// CPU time the minimap's tiles may take per frame.
const MINIMAP_BUDGET: Duration = Duration::from_micros(300);
@@ -57,6 +61,7 @@ impl Game<'_> {
if !playing {
self.extras.mouse = [false; 2];
}
self.sync_reconnect_setting();
self.update_minimap();
// For mods (`render.frame-stats`, `native_frame_rate`).
let now = self.extras_now();
@@ -78,74 +83,6 @@ impl Game<'_> {
let cps = (l.per_second(now) as u32, r.per_second(now) as u32);
(cps, self.extras.mouse)
}
/// Auto-reconnect: a connection to `address` as `name` starts.
pub(super) fn extras_connecting(&mut self, address: &str, name: &str, lan: bool) {
self.extras.reconnect.connecting(address, name, lan);
}
/// The server this game connected to last.
pub(super) fn server_address(&self) -> Option<&str> {
self.extras.reconnect.address()
}
/// The server this game connected to last was found on the local network.
pub(super) fn server_is_lan(&self) -> bool {
self.extras.reconnect.lan()
}
/// Auto-reconnect: the server let the player in.
pub(super) fn extras_joined(&mut self) {
self.extras.reconnect.joined(std::time::Instant::now());
}
/// Auto-reconnect: the player left the server on purpose.
pub(super) fn extras_left(&mut self) {
self.extras.reconnect.reset();
}
/// Auto-reconnect's next attempt after a disconnect, when it is on and
/// the reason allows one.
pub(super) fn reconnect_after(
&mut self,
reason: &str,
key: Option<&str>,
from_server: bool,
) -> Option<Retry> {
if !self.settings.extras.auto_reconnect {
self.extras.reconnect.reset();
return None;
}
let now = std::time::Instant::now();
self.extras.reconnect.ended(reason, key, from_server, now)
}
/// Show the attempt's countdown on the Disconnected screen.
pub(super) fn offer_reconnect(&mut self, retry: Option<Retry>) {
let Some(retry) = retry else {
return;
};
if let Some(title) = self.title.as_mut()
&& let Mode::Multiplayer(multiplayer::Screen::Disconnected { retry: slot, .. }) =
&mut title.mode
{
log::info!(
"Horizon: reconnecting to {} in {} s",
horizon_client::net::shown_address(&retry.address),
retry.seconds_left(std::time::Instant::now())
);
*slot = Some(retry);
}
}
/// Auto-reconnect: connect again once the countdown is over. An
/// online-mode server gets the signed-in account again: `connect_server`
/// takes it from `auth::current()`, held in memory for the whole run
/// (the launcher's session file is gone after the first read).
pub(super) fn extras_retry(&mut self) -> horizon_client::Result<()> {
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
self.connect_server_with_options(
&retry.address,
&retry.name,
None,
retry.lan,
self.realm_session,
)?;
}
Ok(())
}
/// The minimap a mod shows (`native_minimap`): its corner, its side
/// in GUI pixels and its view of the player.
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
@@ -284,7 +221,7 @@ impl Game<'_> {
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
fn effect_rows_height(&self) -> f32 {
let effects = &self.potions.effects.list;
if effects.is_empty() || self.screen != Screen::Playing || self.pvp_effects_shown() {
if effects.is_empty() || self.screen != Screen::Playing || self.effect_icons_hidden() {
return 0.0;
}
let harmful = effects.iter().any(|e| {
@@ -17,26 +17,12 @@
//! other side. Down over a result slot takes one craft or the whole furnace
//! output to the inventory; nothing ever goes into a result slot.
//!
//! Sorting draws a small button by a menu's storage slots and one by the
//! inventory's (an optional key sorts the part under the cursor); the
//! clicks come from `inv_sort::plan`.
use super::{Creative, Game, Screen};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
inv_sort,
items::ItemStack,
keybinds,
menu::Input,
stations::hover_name,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
//! Sorting, which clicks through `tweak_click` too, is in `sorting.rs`.
use crate::game::{Creative, Game, Screen};
use horizon_client::{containers::BlockEntity, items::ItemStack, menu::Input};
#[derive(Default)]
pub(super) struct Tweaks {
pub(in crate::game) struct Tweaks {
drag: Option<Drag>,
/// Wheel steps not yet acted on (a trackpad scrolls in fractions).
wheel: f32,
@@ -44,7 +30,7 @@ pub(super) struct Tweaks {
/// A server's menu the tweaks run in: their clicks are vanilla's in these,
/// and the menu engine knows which slot takes what. Not the menus whose slots
/// cost or trigger something (anvils, enchanting, merchants, beacons, crafters).
pub(super) fn tweak_menu(name: &str) -> bool {
pub(in crate::game) fn tweak_menu(name: &str) -> bool {
name == "inventory"
|| name.starts_with("minecraft:generic_9x")
|| matches!(
@@ -72,7 +58,7 @@ struct Drag {
impl Game<'_> {
/// The open screen is one the tweaks run in, with the extra on.
pub(super) fn tweaks_on(&self) -> bool {
pub(in crate::game) fn tweaks_on(&self) -> bool {
if !self.settings.extras.mouse_tweaks
|| self.screen != Screen::Inventory
|| self.station.is_some()
@@ -87,7 +73,7 @@ impl Game<'_> {
}
}
/// One ordinary click on a game slot. False when nothing took it.
pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
pub(in crate::game) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
if self.remote.is_some() {
return self.remote_menu_ops(&[(Some(slot), button, input)]);
}
@@ -103,7 +89,7 @@ impl Game<'_> {
/// drag (`QUICK_CRAFT`) and not a click: a server that has the hand empty
/// (the stack it was to come from just burned as fuel) ignores a drag, but
/// a click would pick up what's in `slot`.
fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
pub(in crate::game) fn tweak_put(&mut self, slot: usize, right: bool) -> bool {
let kind = i32::from(right) << 2;
let ops = [
(None, kind, Input::QuickCraft),
@@ -123,7 +109,12 @@ impl Game<'_> {
}
/// A press on `slot`: true when the tweaks took it (a right-drag
/// placing), else vanilla's press runs and the tweaks watch the drag.
pub(super) fn tweak_press(&mut self, slot: Option<usize>, right: bool, shift: bool) -> bool {
pub(in crate::game) fn tweak_press(
&mut self,
slot: Option<usize>,
right: bool,
shift: bool,
) -> bool {
self.tweaks.drag = None;
let Some(slot) = slot.filter(|&s| s != 200 && s < Creative::DISPLAY) else {
return false;
@@ -174,7 +165,7 @@ impl Game<'_> {
}
/// The cursor moved while a button is held: true when the tweaks own
/// the drag (vanilla's quick craft drag doesn't grow).
pub(super) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
pub(in crate::game) fn tweak_drag_move(&mut self, slot: Option<usize>) -> bool {
let Some(mut drag) = self.tweaks.drag.take() else {
return false;
};
@@ -222,11 +213,11 @@ impl Game<'_> {
owned
}
/// The button went up: the drag ends.
pub(super) fn tweak_release(&mut self) {
pub(in crate::game) fn tweak_release(&mut self) {
self.tweaks.drag = None;
}
/// The wheel over an inventory screen: true when the tweaks used it.
pub(super) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
pub(in crate::game) fn tweak_scroll(&mut self, delta: f32, slot: Option<usize>) -> bool {
if !self.settings.extras.mouse_tweaks_wheel || !self.tweaks_on() || self.carried.is_some() {
return false;
}
@@ -256,7 +247,7 @@ impl Game<'_> {
result
}
/// `slot` takes one more of `stack`, by the open menu's rules.
fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool {
pub(in crate::game) fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool {
let mut takes = false;
self.menu_session(|menu, env, to_menu| {
takes = to_menu(slot).is_some_and(|i| menu.takes_one(env, i, stack));
@@ -266,7 +257,7 @@ impl Game<'_> {
}
/// Where vanilla's quick move would put `slot`'s items, tried on a copy
/// of the menu that's dropped (`Some(false)` keeps nothing).
fn tweak_landing(&mut self, slot: usize) -> Vec<usize> {
pub(in crate::game) fn tweak_landing(&mut self, slot: usize) -> Vec<usize> {
let slots = self.slots();
let mut landing = Vec::new();
self.menu_session(|menu, env, to_menu| {
@@ -356,144 +347,6 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// The sort buttons of the open screen, with the extra on: their
/// corner (from the screen's) and the slots each sorts. Any menu screen
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
/// right of its storage slots (a chest's title line) when it has some,
/// one by the main inventory's (the "Inventory" label's line).
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
use super::NativeSlotRole as Role;
if !self.settings.extras.sort {
return Vec::new();
}
let roles = self.native_slot_roles();
let of = |role| -> Vec<usize> {
roles
.iter()
.filter(|(_, r)| *r == role)
.map(|(s, _)| *s)
.collect()
};
// Over the section's last column: (160, 5) over a chest's slots.
let right = |slots: &[usize]| {
slots
.iter()
.map(|&s| self.slot_position(s).0 + 8.0)
.fold(f32::MIN, f32::max)
};
let mut buttons = Vec::new();
let storage = of(Role::Storage);
if !storage.is_empty() {
let top = storage
.iter()
.map(|&s| self.slot_position(s).1 - 13.0)
.fold(f32::MAX, f32::min);
buttons.push(((right(&storage), top), storage));
}
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
// above the first row): a mod's screen places its inventory itself.
let main = of(Role::Inventory);
if !main.is_empty() {
let y = self
.native_screen_inventory_y()
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
buttons.push(((right(&main), y), main));
}
buttons
}
/// The sort buttons: a bordered square with three bars, lighter under
/// the cursor.
pub(super) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
let atlas = &self.gui.atlas;
for ((x, y), _) in self.sort_buttons() {
let (x, y) = (ox + x, oy + y);
let (cx, cy) = self.cursor;
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
let face = if hovered {
rgba(220, 220, 220, 255)
} else {
rgba(160, 160, 160, 255)
};
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
SORT_BUTTON - 2.0,
SORT_BUTTON - 2.0,
face,
);
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
let by = y + 2.0 + 2.0 * i as f32;
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
}
}
}
/// A left press on a sort button sorts its slots (true).
pub(super) fn sort_press(&mut self) -> bool {
let Some(r) = self.renderer.as_ref() else {
return false;
};
let batch = HudBatch::new([r.config.width, r.config.height]);
let (ox, oy) = self.inventory_origin(&batch);
let (cx, cy) = self.cursor;
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
let (x, y) = (ox + x, oy + y);
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
});
let Some((_, slots)) = hit else {
return false;
};
self.sort_slots(&slots);
true
}
/// The sort key: sorts the part under the cursor (the container when
/// the cursor is over no slot).
pub(super) fn sort_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
}
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
fn sort_input(&mut self, name: &str) -> bool {
let e = &self.settings.extras;
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
let hovered = self.inventory_target().0;
let slots = buttons
.iter()
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
.or(buttons.first())
.map(|(_, slots)| slots.clone());
let Some(slots) = slots else {
return false;
};
self.sort_slots(&slots);
true
}
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
fn sort_slots(&mut self, slots: &[usize]) {
if self.carried.is_some() {
return;
}
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
let clicks = {
let rank = |s: &ItemStack| {
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
(order, hover_name(&self.pack.names, s))
};
let max = |s: &ItemStack| self.max_stack(s);
inv_sort::plan(&views, &rank, &max)
};
for i in clicks {
self.tweak_click(slots[i], 0, Input::Pickup);
}
}
}
#[cfg(test)]
mod tests {
use super::tweak_menu;
@@ -1,6 +1,6 @@
//! The PvP & HUD page's options in the game (`horizon_client::pvp`, `docs/pvp.md`). Each
//! does nothing while it is off.
use super::{Dim, Game};
use crate::game::Game;
use horizon_client::{
extras::Corner,
extras_hud::Corners,
@@ -14,7 +14,7 @@ use std::sync::Arc;
use winit::event::MouseButton;
#[derive(Default)]
pub(super) struct PvpState {
pub(in crate::game) struct PvpState {
crosshair: CrosshairCache,
/// Each armour slot's item, damage and maximum damage, and whether durability is in percent.
armour: Widget<([Option<(Arc<str>, u32, u32)>; 4], bool)>,
@@ -41,13 +41,13 @@ pub(super) struct PvpState {
speed: pvp_hud::Speed,
speed_shown: Widget<u32>,
/// Each widget drawn last frame and its box on screen, for the HUD editor.
pub(super) boxes: Vec<(pvp_hud::WidgetId, [f32; 4])>,
pub(in crate::game) boxes: Vec<(pvp_hud::WidgetId, [f32; 4])>,
/// The HUD editor, while it is open.
pub(super) editor: Option<super::hud_editor::HudEditor>,
pub(in crate::game) editor: Option<crate::game::hud_editor::HudEditor>,
/// The open chat's box holds a chat search's query (Ctrl+F).
pub(super) chat_search: bool,
pub(in crate::game) chat_search: bool,
/// What the chat box held when the search borrowed it.
pub(super) chat_search_aside: String,
pub(in crate::game) chat_search_aside: String,
}
/// `GuiGraphicsExtractor`'s grey for a detail line (an effect's time left).
const DETAIL: u32 = 0xFF7F_7F7F;
@@ -78,13 +78,13 @@ const ARROWS: [&str; 3] = [
impl Game<'_> {
/// Whether the custom crosshair replaces vanilla's sprite.
pub(super) fn custom_crosshair(&self) -> bool {
pub(in crate::game) fn custom_crosshair(&self) -> bool {
self.settings.extras.pvp.crosshair.custom
}
/// The custom crosshair, centred where vanilla's 15x15 sprite is (`Hud.extractCrosshair`):
/// its outline in black, then the shape, inverting what is under it when its colour is
/// `Inverted`. The runs are cached, so a frame only adds their quads.
pub(super) fn draw_custom_crosshair(&mut self, batch: &mut HudBatch, w: i32, h: i32) {
pub(in crate::game) fn draw_custom_crosshair(&mut self, batch: &mut HudBatch, w: i32, h: i32) {
let mut c = self.settings.extras.pvp.crosshair;
if let Some(hover) = c.hover
&& self.crosshair_living() == Some(true)
@@ -114,7 +114,7 @@ impl Game<'_> {
/// the outline's edges come from) as translucent faces drawn after translucent terrain,
/// pushed out a little so they don't fight the block's own faces. Only while the outline
/// shows (`outline` is its edges).
pub(super) fn outline_fill(
pub(in crate::game) fn outline_fill(
&self,
outline: &[[f64; 6]],
camera: glam::DVec3,
@@ -176,42 +176,33 @@ impl Game<'_> {
}
}
}
/// Once per frame: the options that live outside the game's state.
pub(super) fn pvp_frame(&mut self) {
/// Once per frame: the options that live outside the game's state, and the look the engine
/// draws with (`game/look.rs`), the only way it reads them.
pub(in crate::game) fn pvp_frame(&mut self) {
let tweaks = self.settings.extras.pvp.tweaks;
horizon_client::enchant::set_numeric_levels(tweaks.numeric_enchants);
horizon_hooks::look::update(|look| self.settings.extras.pvp.apply(look));
}
/// Whether this window may draw PvP & HUD's own time and weather: only a client of a server
/// (single player's included) draws them, never the server that ticks the world.
fn own_view(&self) -> bool {
self.remote.is_some() && self.dim == Dim::Overworld
/// Legacy rules: the hits a server resolves follow 1.8.9 (`legacy_rules.rs`).
pub(in crate::game) fn pvp_legacy_rules(&self) -> bool {
self.settings.extras.pvp.combat.rules
}
/// The day time the frame draws (sky, sun, moon, the sky's light): PvP & HUD's time of day,
/// else the world's. The world's own `day_time` and everything that plays by it stay.
pub(super) fn view_day_time(&self) -> i64 {
let fixed = self.settings.extras.pvp.tweaks.time;
horizon_client::pvp::view_day_time(self.world.day_time, fixed.filter(|_| self.own_view()))
}
/// The rain and thunder levels the frame draws (rain, the sky's colour and light, fog): PvP &
/// HUD's weather, else the world's. Gameplay (riptide, mobs, crops) keeps the world's.
pub(super) fn view_weather(&self, partial: f32) -> (f32, f32) {
match self.settings.extras.pvp.tweaks.weather {
Some(w) if self.own_view() && self.can_have_weather() => w.levels(),
_ => self.weather_levels(partial),
}
/// The server's legacy rules, from server.toml or the window's options.
pub(in crate::game) fn set_pvp_legacy_rules(&mut self, on: bool) {
self.settings.extras.pvp.combat.rules = on;
}
/// This player attacked entity `id`, `distance` from the eyes to where the crosshair's
/// ray met its box (the combo counter waits for the server's report; the reach widget
/// shows the distance).
pub(super) fn pvp_attacked(&mut self, id: i32, distance: f64) {
pub(in crate::game) fn pvp_attacked(&mut self, id: i32, distance: f64) {
let now = self.started.elapsed().as_secs_f64();
self.pvp.combo.attacked(id, now);
self.pvp.reach.hit(distance, now);
}
/// A mouse press, counted for clicks per second while playing with a widget that shows them.
pub(super) fn pvp_click(&mut self, button: MouseButton) {
pub(in crate::game) fn pvp_click(&mut self, button: MouseButton) {
let hud = &self.settings.extras.pvp.hud;
if !(hud.cps || hud.keys) || self.screen != super::Screen::Playing {
if !(hud.cps || hud.keys) || self.screen != crate::game::Screen::Playing {
return;
}
let i = match button {
@@ -232,7 +223,7 @@ impl Game<'_> {
}
/// The server reports entity `id` hurt; `own` when it is this player,
/// `by_me` whether this player was the damage's direct entity (if named).
pub(super) fn pvp_hurt(&mut self, id: i32, own: bool, by_me: Option<bool>) {
pub(in crate::game) fn pvp_hurt(&mut self, id: i32, own: bool, by_me: Option<bool>) {
if own {
self.pvp.combo.broken();
} else {
@@ -240,13 +231,9 @@ impl Game<'_> {
self.pvp.combo.hurt(id, by_me, now);
}
}
/// Whether the effects widget replaces vanilla's effect icons (`Hud.extractEffects`).
pub(super) fn pvp_effects_shown(&self) -> bool {
self.settings.extras.pvp.hud.effects
}
/// The HUD widgets that are on, after the minimap and before mods' boxes. Each rebuilds its
/// lines only when what it shows changed.
pub(super) fn draw_pvp_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
pub(in crate::game) fn draw_pvp_hud(&mut self, batch: &mut HudBatch, corners: &mut Corners) {
let hud = self.settings.extras.pvp.hud;
if self.player.is_none() || !hud.any() {
// The editor frames only what was drawn.
@@ -267,8 +254,8 @@ impl Game<'_> {
let at = [p.position.x, p.position.y, p.position.z].map(|v| v.floor() as i32);
(at, pvp_hud::facing(self.yaw))
});
// The address is copied only when it changed (`server_address` borrows the whole game).
let server = self.server_address().filter(|_| multiplayer);
// The address is copied only when it changed.
let server = self.last_server.address().filter(|_| multiplayer);
let server = (hud.server && self.pvp.server.next.as_deref() != server)
.then(|| server.map(str::to_owned));
let now = self.started.elapsed().as_secs_f64();
@@ -691,7 +678,7 @@ impl Game<'_> {
false => s.item == held.item,
};
let slots = self.inventory.iter().map(Option::as_ref).enumerate();
let offhand = std::iter::once((super::shield::OFFHAND_SLOT, self.offhand.as_ref()));
let offhand = std::iter::once((crate::game::shield::OFFHAND_SLOT, self.offhand.as_ref()));
let mut found = slots
.chain(offhand)
.filter_map(|(i, s)| Some((i, s?)))
@@ -0,0 +1,11 @@
//! Auto-reconnect (`autoReconnect`, docs/extras.md) runs on the engine's connection hooks
//! (`horizon_client::reconnect::HOOKS`, registered in `entry.rs`). Its setting still lives in
//! the engine's extras, so it is handed over here; it leaves with the extras page (plan M5).
use crate::game::Game;
impl Game<'_> {
/// Once per frame: the `autoReconnect` setting, for the hook to read when a connection ends.
pub(in crate::game) fn sync_reconnect_setting(&self) {
horizon_client::reconnect::HOOKS.set_enabled(self.settings.extras.auto_reconnect);
}
}
@@ -0,0 +1,155 @@
//! Sorting (`sort`, `sortKey`; #332, `docs/extras.md`), off by default: a small button by a
//! menu's storage slots and one by the inventory's (an optional key sorts the part under the
//! cursor). The clicks come from `inv_sort::plan` and go through the Mouse Tweaks' ordinary slot
//! clicks (`mouse_tweaks.rs`), so they work wherever vanilla's do.
use crate::game::Game;
use horizon_client::{
hud::{HudBatch, rgba},
inv_sort,
items::ItemStack,
keybinds,
menu::Input,
stations::hover_name,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// A sort button's side in GUI pixels.
const SORT_BUTTON: f32 = 9.0;
impl Game<'_> {
/// The sort buttons of the open screen, with the extra on: their
/// corner (from the screen's) and the slots each sorts. Any menu screen
/// gets them, by its slots' roles (`native_slot_roles`): one by the top
/// right of its storage slots (a chest's title line) when it has some,
/// one by the main inventory's (the "Inventory" label's line).
fn sort_buttons(&self) -> Vec<((f32, f32), Vec<usize>)> {
use crate::game::NativeSlotRole as Role;
if !self.settings.extras.sort {
return Vec::new();
}
let roles = self.native_slot_roles();
let of = |role| -> Vec<usize> {
roles
.iter()
.filter(|(_, r)| *r == role)
.map(|(s, _)| *s)
.collect()
};
// Over the section's last column: (160, 5) over a chest's slots.
let right = |slots: &[usize]| {
slots
.iter()
.map(|&s| self.slot_position(s).0 + 8.0)
.fold(f32::MIN, f32::max)
};
let mut buttons = Vec::new();
let storage = of(Role::Storage);
if !storage.is_empty() {
let top = storage
.iter()
.map(|&s| self.slot_position(s).1 - 13.0)
.fold(f32::MAX, f32::min);
buttons.push(((right(&storage), top), storage));
}
// On the "Inventory" label's line (`inventoryLabelY`, height - 94, 12
// above the first row): a mod's screen places its inventory itself.
let main = of(Role::Inventory);
if !main.is_empty() {
let y = self
.native_screen_inventory_y()
.map_or(self.screen_height() - 95.0, |y| y - 13.0);
buttons.push(((right(&main), y), main));
}
buttons
}
/// The sort buttons: a bordered square with three bars, lighter under
/// the cursor.
pub(in crate::game) fn draw_sort_buttons(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
let atlas = &self.gui.atlas;
for ((x, y), _) in self.sort_buttons() {
let (x, y) = (ox + x, oy + y);
let (cx, cy) = self.cursor;
let hovered = cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON;
let face = if hovered {
rgba(220, 220, 220, 255)
} else {
rgba(160, 160, 160, 255)
};
batch.fill(atlas, x, y, SORT_BUTTON, SORT_BUTTON, rgba(55, 55, 55, 255));
batch.fill(
atlas,
x + 1.0,
y + 1.0,
SORT_BUTTON - 2.0,
SORT_BUTTON - 2.0,
face,
);
for (i, w) in [5.0, 4.0, 3.0].into_iter().enumerate() {
let by = y + 2.0 + 2.0 * i as f32;
batch.fill(atlas, x + 2.0, by, w, 1.0, rgba(55, 55, 55, 255));
}
}
}
/// A left press on a sort button sorts its slots (true).
pub(in crate::game) fn sort_press(&mut self) -> bool {
let Some(r) = self.renderer.as_ref() else {
return false;
};
let batch = HudBatch::new([r.config.width, r.config.height]);
let (ox, oy) = self.inventory_origin(&batch);
let (cx, cy) = self.cursor;
let hit = self.sort_buttons().into_iter().find(|((x, y), _)| {
let (x, y) = (ox + x, oy + y);
cx >= x && cx < x + SORT_BUTTON && cy >= y && cy < y + SORT_BUTTON
});
let Some((_, slots)) = hit else {
return false;
};
self.sort_slots(&slots);
true
}
/// The sort key: sorts the part under the cursor (the container when
/// the cursor is over no slot).
pub(in crate::game) fn sort_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.sort_input(name))
}
pub(in crate::game) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
fn sort_input(&mut self, name: &str) -> bool {
let e = &self.settings.extras;
if !e.sort || e.sort_key != name || self.recipe_search_focused() {
return false;
}
let buttons = self.sort_buttons();
let hovered = self.inventory_target().0;
let slots = buttons
.iter()
.find(|(_, slots)| hovered.is_some_and(|h| slots.contains(&h)))
.or(buttons.first())
.map(|(_, slots)| slots.clone());
let Some(slots) = slots else {
return false;
};
self.sort_slots(&slots);
true
}
/// Sorts `slots` with ordinary left clicks (needs an empty hand).
fn sort_slots(&mut self, slots: &[usize]) {
if self.carried.is_some() {
return;
}
let views: Vec<Option<ItemStack>> = slots.iter().map(|&s| self.slot_view(s)).collect();
let clicks = {
let rank = |s: &ItemStack| {
let order = self.creative_screen.order_of(s).unwrap_or(usize::MAX);
(order, hover_name(&self.pack.names, s))
};
let max = |s: &ItemStack| self.max_stack(s);
inv_sort::plan(&views, &rank, &max)
};
for i in clicks {
self.tweak_click(slots[i], 0, Input::Pickup);
}
}
}
@@ -238,7 +238,7 @@ impl Game<'_> {
let off_hand_empty = items[1].is_none();
// Combat feel Legacy: a sword is held up while use is held, as 1.8.9's block looked.
// Only the picture changes: the clicks and what they send stay 26.3's.
let legacy_block = self.settings.extras.pvp.combat.legacy
let legacy_block = self.legacy_pose()
&& self.right
&& self.screen == Screen::Playing
&& items[0]
@@ -426,7 +426,7 @@ impl Game<'_> {
let mut m = to_world
* horizon_client::first_person::item_pose(left, inverse_height, attack, pose);
// PvP & HUD's shield size (100% is vanilla's), toward the hand holding it.
let shield_size = self.settings.extras.pvp.tweaks.shield_size;
let shield_size = self.shield_size();
if shield_size != 100 && &*stack.item == "minecraft:shield" {
m *= glam::Mat4::from_scale(glam::Vec3::splat(f32::from(shield_size) / 100.0));
}
@@ -449,7 +449,7 @@ impl Game<'_> {
};
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, cit_hand);
// Combat feel Legacy: 1.8.9's pose for a flat item drawn with vanilla's display.
if self.settings.extras.pvp.combat.legacy
if self.legacy_pose()
&& !state.layers.is_empty()
&& state
.layers
+1 -1
View File
@@ -568,7 +568,7 @@ impl Game<'_> {
self.hands_busy(),
self.hand_strength.swap_scale(1.0, speed),
changed,
self.settings.extras.pvp.combat.legacy,
self.legacy_pose(),
);
for h in 0..2 {
if replace[h] {
+2 -2
View File
@@ -477,7 +477,7 @@ impl Game<'_> {
.store(settings.entity_distance.to_bits(), Ordering::Relaxed);
flags
.legacy_rules
.store(settings.extras.pvp.combat.rules, Ordering::Relaxed);
.store(self.pvp_legacy_rules(), Ordering::Relaxed);
let (ready_tx, ready) = channel();
let (tasks, server_tasks) = channel();
let server_flags = flags.clone();
@@ -1095,7 +1095,7 @@ impl Game<'_> {
// IntegratedServer.getScaledTrackingDistance reads the option each time.
self.settings.entity_distance =
f32::from_bits(flags.entity_distance.load(Ordering::Relaxed));
self.settings.extras.pvp.combat.rules = flags.legacy_rules.load(Ordering::Relaxed);
self.set_pvp_legacy_rules(flags.legacy_rules.load(Ordering::Relaxed));
let Some(host) = self.lan.as_mut() else {
return;
};
@@ -2,6 +2,9 @@
//! drags, throws and the press/release of a slot click.
use super::*;
/// What crates above the engine get of these screens (`horizon_hooks::containers`).
mod containers;
/// Q throws the whole stack with Control down: the same modifier word the
/// screens read (`has_control_down`), never a latched sprint toggle.
pub(super) fn drop_whole_stack(modifiers: winit::keyboard::ModifiersState) -> bool {
@@ -161,7 +164,7 @@ impl Game<'_> {
}
return;
}
if self.tweak_press(slot, right, shift) {
if self.tweak_press(slot, right, shift) || self.container_press(right) {
return;
}
if let Some(slot) = slot
@@ -263,7 +266,9 @@ impl Game<'_> {
}
/// Mouse movement while a quick craft drag is running.
pub(super) fn inventory_drag_move(&mut self) {
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0) {
if self.tweaks_on() && self.tweak_drag_move(self.inventory_target().0)
|| self.container_drag()
{
return;
}
let Some((ty, slots)) = self.inv_drag.clone() else {
@@ -426,6 +431,7 @@ impl Game<'_> {
pub(super) fn inventory_mouse_release(&mut self, right: bool) {
use horizon_client::container_screen::{Release, Target};
self.tweak_release();
self.container_release();
let (slot, outside) = self.inventory_target();
let dragged = self.inv_drag.as_ref().is_some_and(|(_, s)| !s.is_empty());
let release = self.inv_clicks.release(
@@ -0,0 +1,303 @@
//! The engine's side of the container screen API (`horizon_hooks::containers`, docs/layers.md,
//! "Container screens"): the open menu screen as a [`Container`] for the length of one hook call,
//! and the input and drawing that reach the hooks. With no hook registered nothing here is built.
use crate::game::{Game, NativeSlotRole, Screen};
use horizon_client::{
extras_hud, hud::HudBatch, items::ItemStack, keybinds, menu::Input, stations::hover_name,
};
use horizon_hooks::{
containers::{Click, Container, Role, Slot, SlotId, Stack},
engine,
};
use winit::{event::MouseButton, keyboard::KeyCode};
/// The open screen's menu, over the game for one call. `origin`: the panel's top left corner in
/// GUI pixels.
struct Open<'g, 'a> {
game: &'g mut Game<'a>,
origin: (f32, f32),
}
/// The same, read only: what drawing gets.
struct View<'g, 'a> {
game: &'g Game<'a>,
origin: (f32, f32),
}
fn role(role: NativeSlotRole) -> Role {
match role {
NativeSlotRole::Hotbar => Role::Hotbar,
NativeSlotRole::Inventory => Role::Inventory,
NativeSlotRole::Storage => Role::Storage,
NativeSlotRole::CraftingGrid | NativeSlotRole::Input => Role::Input,
NativeSlotRole::Result => Role::Result,
NativeSlotRole::Equipment => Role::Equipment,
}
}
/// Stacks that merge (`same_item_same_components`) share a kind.
fn kind(stack: &ItemStack) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
stack.item.hash(&mut h);
stack.damage.hash(&mut h);
format!("{:?}", stack.extra).hash(&mut h);
h.finish()
}
fn slot_id(slot: usize) -> SlotId {
SlotId(u32::try_from(slot).unwrap_or(u32::MAX))
}
fn game_slot(slot: SlotId) -> usize {
slot.0 as usize
}
impl<'a> Game<'a> {
/// The open menu's type: the server's, else the menu this window opened itself.
fn container_menu(&self) -> &str {
if let Some(menu) = self.remote_menu_type() {
return menu;
}
use horizon_client::containers::BlockEntity;
if self.mount_active() {
return "horizon:mount";
}
if self.station.is_some() {
return "horizon:station";
}
match self.container_kind() {
None if self.craft_size == 3 => "minecraft:crafting",
None => "inventory",
Some(BlockEntity::Chest(_)) => "minecraft:generic_9x3",
Some(BlockEntity::Furnace(_)) => "minecraft:furnace",
Some(_) => "horizon:block",
}
}
fn container_slots(&self) -> Vec<Slot> {
self.native_slot_roles()
.into_iter()
.map(|(s, r)| {
let (x, y) = self.slot_position(s);
Slot {
id: slot_id(s),
role: role(r),
x,
y,
}
})
.collect()
}
fn container_stack(&self, stack: &ItemStack) -> Stack {
Stack {
item: stack.item.to_string(),
count: stack.count,
max: self.max_stack(stack),
kind: kind(stack),
}
}
/// The stack of `kind` the screen shows, on the cursor or in a slot.
fn container_find(&mut self, of: &Stack) -> Option<ItemStack> {
if let Some(c) = self.carried.as_ref().filter(|c| kind(c) == of.kind) {
return Some(c.clone());
}
self.slots()
.into_iter()
.filter_map(|s| self.slot_view(s))
.find(|s| kind(s) == of.kind)
}
/// The panel's top left corner, None without a window.
fn container_origin(&self) -> Option<(f32, f32)> {
let r = self.renderer.as_ref()?;
Some(self.inventory_origin(&HudBatch::new([r.config.width, r.config.height])))
}
/// The open screen for the hooks' input, None when no hook may take it.
fn container_open(&mut self) -> Option<Open<'_, 'a>> {
if self.screen != Screen::Inventory || !engine::containers() {
return None;
}
let origin = self.container_origin()?;
Some(Open { game: self, origin })
}
fn hovered(&self) -> Option<SlotId> {
self.inventory_target().0.map(slot_id)
}
/// A press over the panel: true when a hook took it, and vanilla's drag doesn't start.
pub(in crate::game) fn container_press(&mut self, right: bool) -> bool {
let slot = self.hovered();
let taken = self
.container_open()
.is_some_and(|mut c| engine::container_press(&mut c, slot, right));
if taken {
self.inv_drag = None;
self.inv_clicks.skip_release();
}
taken
}
/// The cursor moved with a button held: true when a hook owns the drag, which ends vanilla's.
pub(in crate::game) fn container_drag(&mut self) -> bool {
let slot = self.hovered();
let taken = self
.container_open()
.is_some_and(|mut c| engine::container_drag(&mut c, slot));
if taken {
self.inv_drag = None;
self.inv_clicks.skip_release();
}
taken
}
pub(in crate::game) fn container_release(&mut self) {
if let Some(mut c) = self.container_open() {
engine::container_release(&mut c);
}
}
/// The wheel over an inventory screen: true when the Mouse Tweaks or a hook used it.
pub(in crate::game) fn container_scroll(&mut self, steps: f32) -> bool {
let slot = self.inventory_target().0;
if self.tweak_scroll(steps, slot) {
return true;
}
let slot = slot.map(slot_id);
self.container_open()
.is_some_and(|mut c| engine::container_wheel(&mut c, slot, steps))
}
/// A key the screen got: sorting's key, else a hook's.
pub(in crate::game) fn container_key_press(&mut self, key: KeyCode) -> bool {
self.sort_key_press(key)
|| keybinds::key_name(key).is_some_and(|name| self.container_key(name))
}
/// A mouse button the screen got, as a key.
pub(in crate::game) fn container_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_mouse_press(button) || self.container_key(&keybinds::mouse_name(button))
}
fn container_key(&mut self, name: &str) -> bool {
if self.recipe_search_focused() {
return false;
}
let slot = self.hovered();
self.container_open()
.is_some_and(|mut c| engine::container_key(&mut c, slot, name))
}
/// Draws the sort buttons and the hooks' drawing over the panel at (`ox`, `oy`).
pub(in crate::game) fn draw_container_extras(&self, batch: &mut HudBatch, ox: f32, oy: f32) {
self.draw_sort_buttons(batch, ox, oy);
if !engine::containers() {
return;
}
let view = View {
game: self,
origin: (ox, oy),
};
extras_hud::draw_on_panel(batch, &self.gui.atlas, (ox, oy), |canvas| {
engine::container_draw(&view, canvas);
});
}
}
/// What both readings answer alike.
macro_rules! reads {
() => {
fn menu(&self) -> &str {
self.game.container_menu()
}
fn slots(&self) -> Vec<Slot> {
self.game.container_slots()
}
fn carried(&self) -> Option<Stack> {
let c = self.game.carried.as_ref()?;
Some(self.game.container_stack(c))
}
fn spread(&self) -> usize {
self.game.inv_drag.as_ref().map_or(0, |(_, s)| s.len())
}
fn cursor(&self) -> (f32, f32) {
let (x, y) = self.game.cursor;
(x - self.origin.0, y - self.origin.1)
}
fn shift(&self) -> bool {
self.game.has_shift_down()
}
};
}
impl Container for Open<'_, '_> {
reads!();
fn stack(&mut self, slot: SlotId) -> Option<Stack> {
let s = self.game.slot_view(game_slot(slot))?;
Some(self.game.container_stack(&s))
}
fn takes_one(&mut self, slot: SlotId, of: &Stack) -> bool {
self.game
.container_find(of)
.is_some_and(|s| self.game.tweak_takes_one(game_slot(slot), &s))
}
fn landing(&mut self, slot: SlotId) -> Vec<SlotId> {
let landing = self.game.tweak_landing(game_slot(slot));
landing.into_iter().map(slot_id).collect()
}
fn click(&mut self, slot: SlotId, click: Click) -> bool {
let slot = game_slot(slot);
match click {
Click::Pick { right } => self.game.tweak_click(slot, i32::from(right), Input::Pickup),
Click::QuickMove => self.game.tweak_click(slot, 0, Input::QuickMove),
Click::Put { one } => self.game.tweak_put(slot, one),
_ => false,
}
}
fn order(&mut self, slot: SlotId) -> (u32, String) {
let Some(s) = self.game.slot_view(game_slot(slot)) else {
return (u32::MAX, String::new());
};
let order = self.game.creative_screen.order_of(&s);
let order = order.map_or(u32::MAX, |o| u32::try_from(o).unwrap_or(u32::MAX));
(order, hover_name(&self.game.pack.names, &s))
}
}
/// Drawing gets `&dyn Container`, which can't call the `&mut self` reads.
impl Container for View<'_, '_> {
reads!();
fn stack(&mut self, _: SlotId) -> Option<Stack> {
None
}
fn takes_one(&mut self, _: SlotId, _: &Stack) -> bool {
false
}
fn landing(&mut self, _: SlotId) -> Vec<SlotId> {
Vec::new()
}
fn click(&mut self, _: SlotId, _: Click) -> bool {
false
}
fn order(&mut self, _: SlotId) -> (u32, String) {
(u32::MAX, String::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::items::ItemStack;
fn stack(item: &str, count: u32, damage: u32) -> ItemStack {
ItemStack {
item: item.into(),
count,
damage,
extra: None,
}
}
#[test]
fn stacks_that_merge_share_a_kind() {
let a = kind(&stack("minecraft:stone", 1, 0));
assert_eq!(
a,
kind(&stack("minecraft:stone", 64, 0)),
"the count is no part"
);
assert_ne!(a, kind(&stack("minecraft:dirt", 1, 0)));
let worn = stack("minecraft:iron_sword", 1, 3);
assert_ne!(kind(&worn), kind(&stack("minecraft:iron_sword", 1, 0)));
}
}
@@ -571,7 +571,7 @@ impl Game<'_> {
return;
};
// Combat feel Legacy draws a hit as 1.8.9 did: no sweep, no damage hearts.
if self.settings.extras.pvp.combat.hides_particle(options.ty) {
if self.particle_hidden(options.ty) {
return;
}
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
@@ -8,7 +8,7 @@ impl Game<'_> {
/// Whether the hits this game resolves follow 1.8.9: legacy rules are on and this game
/// is the server (its own server thread, a LAN host, a dedicated server).
pub(super) fn legacy_rules(&self) -> bool {
self.remote.is_none() && self.settings.extras.pvp.combat.rules
self.remote.is_none() && self.pvp_legacy_rules()
}
/// `melee_damage` under legacy rules: (damage, attack strength, critical, enchanted).
@@ -200,7 +200,7 @@ pub(super) fn loading_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
look: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
+91
View File
@@ -0,0 +1,91 @@
//! What a crate above may change in how the game draws (`horizon_hooks::look`): the engine reads
//! it here only, and each reading is vanilla's while nothing set a look. PvP & HUD sets it today
//! (`game/extras/pvp.rs`).
use super::{Dim, Game};
use horizon_client::{chat::ChatQol, particles::ParticleType};
use horizon_hooks::look::{Chat, Frame, engine::get as look};
impl Game<'_> {
/// 1.8.9's pictures of a fight: the sword block, the flat item pose and the hand swing.
pub(super) fn legacy_pose(&self) -> bool {
look().legacy_pose
}
pub(super) fn attack_indicator_hidden(&self) -> bool {
look().hide_attack_indicator
}
/// A sound a server sent that this player doesn't hear (`Look::mutes_sound`).
pub(super) fn sound_muted(&self, name: &str) -> bool {
look().mutes_sound(name)
}
/// A particle a server sent that this player doesn't see: with the attack particles hidden,
/// the sweep and the damage hearts, which 1.9 added to a hit (1.8.9's show crits only).
pub(super) fn particle_hidden(&self, ty: ParticleType) -> bool {
let attack = matches!(
ty,
ParticleType::SweepAttack | ParticleType::DamageIndicator
);
attack && look().hide_attack_particles
}
/// The block outline, hurt overlay and glint colours the renderer draws with.
pub(super) fn look_frame(&self) -> Frame {
look().frame
}
/// The enchantment glint's colour texel, 0 for vanilla's.
pub(super) fn glint_texel(&self) -> u32 {
look().frame.glint
}
/// The held shield's size in percent (100 is vanilla's).
pub(super) fn shield_size(&self) -> u8 {
look().shield_size
}
/// The totem animation's scale (1 is vanilla's).
pub(super) fn totem_scale(&self) -> f32 {
look().totem_scale
}
/// How far the first-person fire overlay is lowered, as a fraction (0 is vanilla's).
pub(super) fn fire_lower(&self) -> f32 {
look().fire_lower
}
/// Lava's fog as with fire resistance, and water's as fully adapted.
pub(super) fn clear_fluids(&self) -> (bool, bool) {
let look = look();
(look.clear_lava, look.clear_water)
}
/// The chat's extras: Ctrl+F search, copy on click, timestamps, repeats counted.
pub(super) fn chat_look(&self) -> Chat {
look().chat
}
/// What the chat lays its lines out with.
pub(super) fn chat_qol(&self) -> ChatQol {
let chat = look().chat;
ChatQol {
timestamps: chat.timestamps,
compact: chat.compact,
}
}
/// Whether vanilla's status effect icons are left out (`Hud.extractEffects`).
pub(super) fn effect_icons_hidden(&self) -> bool {
look().hide_effect_icons
}
/// Whether this window may draw its own time and weather: only a client of a server (single
/// player's included) draws them, never the server that ticks the world.
fn own_view(&self) -> bool {
self.remote.is_some() && self.dim == Dim::Overworld
}
/// The day time the frame draws (sky, sun, moon, the sky's light): the look's time of day,
/// else the world's. The world's own `day_time` and everything that plays by it stay.
pub(super) fn view_day_time(&self) -> i64 {
if !self.own_view() {
return self.world.day_time;
}
look().day_time(self.world.day_time)
}
/// The rain and thunder levels the frame draws (rain, the sky's colour and light, fog): the
/// look's weather, else the world's. Gameplay (riptide, mobs, crops) keeps the world's.
pub(super) fn view_weather(&self, partial: f32) -> (f32, f32) {
match look().weather {
Some(levels) if self.own_view() && self.can_have_weather() => levels,
_ => self.weather_levels(partial),
}
}
}
+1 -1
View File
@@ -299,7 +299,7 @@ impl Game<'_> {
/// address isn't, even on this computer or the local network: an address can be a proxy, a
/// tunnel or a friend's relay as well.
fn direct_craft_free_server(&self) -> bool {
self.server_is_lan()
self.last_server.lan()
|| self
.remote
.as_ref()
+1 -1
View File
@@ -62,7 +62,7 @@ impl Game<'_> {
}
return Ok(());
}
if self.sort_key_press(key)
if self.container_key_press(key)
|| horizon_client::keybinds::key_name(key)
.is_some_and(|name| self.hud_editor_key_press(name))
{
+9 -9
View File
@@ -979,7 +979,7 @@ impl Game<'_> {
lan: bool,
realm: bool,
) -> Result<()> {
self.realm_session = realm;
self.last_server.realm = realm;
self.reset_tutorial();
self.perf.world_opening();
// A server's own biomes come over the connection; the last local
@@ -1027,7 +1027,7 @@ impl Game<'_> {
root.clone(),
),
None => {
self.extras_connecting(address, name, lan);
self.last_server.connecting(address, name, lan);
match cookies {
Some(cookies) => Session::connect_transferred(
address,
@@ -1522,7 +1522,7 @@ impl Game<'_> {
self.finish_perf();
if reason.is_none() {
// Leaving on purpose: nothing for auto-reconnect to retry.
self.extras_left();
self.last_server.left();
}
self.clear_waypoints();
self.hud_disconnected();
@@ -2288,7 +2288,7 @@ impl Game<'_> {
uuid,
} => {
self.set_registries(registries);
self.extras_joined();
self.last_server.joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
@@ -2430,9 +2430,9 @@ impl Game<'_> {
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
let plan = self.last_server.ended(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
horizon_client::last_server::offer(self.title.as_mut(), plan);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
@@ -3873,7 +3873,7 @@ impl Game<'_> {
C::Explode(p) => self.remote_explode(&p),
C::Sound(p) => {
if let Some(name) = self.remote_sound_name(&p.sound)
&& !self.settings.extras.pvp.combat.mutes_sound(&name)
&& !self.sound_muted(&name)
{
let at = [
f64::from(p.x) / 8.0,
@@ -3895,7 +3895,7 @@ impl Game<'_> {
}
C::SoundEntity(p) => {
if let Some(name) = self.remote_sound_name(&p.sound)
&& !self.settings.extras.pvp.combat.mutes_sound(&name)
&& !self.sound_muted(&name)
&& let Some(state) = self.remote_sound_entity_state(p.id)
{
let source = usize::try_from(p.source)
@@ -6746,7 +6746,7 @@ impl Game<'_> {
// (`KeyboardInput.tick`: 8 directions, full speed). The server only hears those keys
// (`ServerboundPlayerInputPacket`), and an anticheat that predicts from them pulls back
// a player moving at the stick's own angle and speed.
if self.integrated.is_none() && self.server_address().is_some() {
if self.integrated.is_none() && self.last_server.address().is_some() {
keys.analog = None;
}
let [mut strafe, mut forward] = keys.direction();
@@ -164,8 +164,8 @@ impl Game<'_> {
boss_world_fog: self.boss_overlay.should_create_world_fog(),
spectator: self.spectator(),
fire_resistance: self.potions.effects.get("fire_resistance").is_some()
|| self.settings.extras.pvp.tweaks.clear_lava,
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
|| self.clear_fluids().0,
full_water_vision: self.clear_fluids().1,
night_vision: self.fog_night_vision(),
environment: self.pack.polytone_colors.env,
};
@@ -405,7 +405,7 @@ impl Game<'_> {
on_fire: self.camera_type.first_person()
&& !self.spectator()
&& self.player.as_ref().is_some_and(|p| p.fire_ticks > 0),
fire_lower: f32::from(self.settings.extras.pvp.tweaks.fire_lower) / 100.0,
fire_lower: self.fire_lower(),
gui_overlays: &gui_overlays,
})
}
+1 -1
View File
@@ -150,7 +150,7 @@ impl Game<'_> {
}
/// The server's name: a local world's name, a server's entry in the list.
fn social_server_name(&self) -> String {
match (&self.integrated, self.server_address()) {
match (&self.integrated, self.last_server.address()) {
(Some(server), _) => server.world_name.clone(),
(None, Some(address)) if self.remote.is_some() => address.to_owned(),
_ => self.level.name.clone(),
+3 -1
View File
@@ -261,6 +261,8 @@ impl<'a> Split<'a> {
.get(i.wrapping_sub(1))
.map(|g| g.options.clone()),
);
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
horizon_hooks::engine::set_seat(seat - 1);
self.turn = i;
}
@@ -513,7 +515,7 @@ impl<'a> Split<'a> {
if self.host.integrated.is_some() || self.host.remote.is_none() {
return None;
}
self.host.server_address().map(str::to_owned)
self.host.last_server.address().map(str::to_owned)
}
/// The pads' players who accepted, and the testing players (`HORIZON_SPLIT`, one a frame),
@@ -126,6 +126,7 @@ impl Split<'_> {
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
horizon_hooks::engine::set_seat(seat - 1);
let mut settings = self.host.settings.clone();
if pad.is_some() {
// The Controller setting is P1's own: a player who pressed a pad to join plays
@@ -135,6 +136,7 @@ impl Split<'_> {
}
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
horizon_hooks::engine::set_seat(0);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
@@ -299,16 +301,31 @@ impl Split<'_> {
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
let made = if options.exists() {
None
} else {
std::fs::create_dir_all(&profile)?;
let mut new = self.host.settings.clone();
// The Controller setting is P1's, as on the profile page (`join`).
new.controller.enabled |= self.guests[i].pad.is_some();
// On P1's seat, so the crates' settings stores write P1's values too.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(0);
new.save(&options);
new
horizon_hooks::engine::set_seat(turn);
Some(new)
};
// The crates' stores load into the guest's seat, before its turn starts below.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
let mut settings = match made {
Some(new) => {
horizon_client::settings::Settings::load_stores(&options);
new
}
None => horizon_client::settings::Settings::load(&options),
};
horizon_hooks::engine::set_seat(turn);
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
settings.controller.enabled |= self.guests[i].pad.is_some();
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
+1 -1
View File
@@ -206,7 +206,7 @@ impl Game<'_> {
to_world.transform_vector3(b).normalize(),
);
// PvP & HUD's totem animation size (100% is vanilla's), about the item's centre.
let size = f32::from(self.settings.extras.pvp.tweaks.totem_size) / 100.0;
let size = self.totem_scale();
let pose = pose * glam::Mat4::from_scale(glam::Vec3::splat(size));
self.submit_item_lit(&state, to_world * pose, vertex_light(0xFF), lights, out);
}
@@ -1,8 +1,24 @@
//! Opening and closing a local world from the client: the title screen's actions, entering a
//! world on the server thread, and saving and leaving it on exit.
//! world on the server thread, and saving and leaving it on exit. Also connecting again to a
//! server when an attempt a hook planned is due (`last_server.rs`).
use super::*;
impl Game<'_> {
/// Connects again once a planned attempt's countdown is over. An online-mode server gets the
/// signed-in account again: `connect_server` takes it from `auth::current()`, held in memory
/// for the whole run (the launcher's session file is gone after the first read).
pub(super) fn reconnect_when_due(&mut self) -> Result<()> {
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
self.connect_server_with_options(
&retry.address,
&retry.name,
None,
retry.lan,
self.last_server.realm,
)?;
}
Ok(())
}
/// `onAbort`: the confirm screen's Back or Escape reopens the Data
/// Packs screen with the selection it had.
pub(super) fn reopen_data_packs(&mut self, event_loop: &dyn EventLoopCtl) -> Result<()> {
+96
View File
@@ -0,0 +1,96 @@
//! The multiplayer server this window connected to last, which splitscreen players join and the
//! friends service's presence names, and the connection's lifecycle as the hooks of the layers
//! above hear it (`horizon_hooks`: connecting, joined, left, ended). A hook may plan another
//! attempt when a connection ends; the Disconnected screen shows its countdown ([`offer`]).
use crate::{multiplayer, net, title};
use horizon_hooks::{Ended, Reconnect, Server, engine};
/// What the window knows of its last server. Forgotten when the player leaves it, or when a
/// connection ends with no attempt planned.
#[derive(Debug, Default)]
pub struct LastServer {
address: Option<String>,
lan: bool,
/// The open connection joined from Realms: `ServerData.isRealm` for the friends service's
/// presence status.
pub realm: bool,
}
impl LastServer {
/// A connection to a multiplayer server starts.
pub fn connecting(&mut self, address: &str, name: &str, lan: bool) {
self.address = Some(address.to_owned());
self.lan = lan;
engine::connecting(&Server::new(address, name, lan));
}
/// The server let the player in.
pub fn joined(&self) {
engine::joined();
}
/// The player left on purpose.
pub fn left(&mut self) {
self.forget();
engine::left();
}
/// The connection ended: the attempt a hook plans, if any.
pub fn ended(
&mut self,
reason: &str,
key: Option<&str>,
from_server: bool,
) -> Option<Reconnect> {
let plan = engine::ended(&Ended::new(reason, key, from_server));
if plan.is_none() {
self.forget();
}
plan
}
fn forget(&mut self) {
self.address = None;
self.lan = false;
}
/// The server's address, while the window keeps it.
pub fn address(&self) -> Option<&str> {
self.address.as_deref()
}
/// That server was found on the local network.
pub fn lan(&self) -> bool {
self.lan
}
}
/// Shows a planned attempt's countdown on the Disconnected screen.
pub fn offer(title: Option<&mut title::Title>, plan: Option<Reconnect>) {
let Some(plan) = plan else {
return;
};
if let Some(title) = title
&& let title::Mode::Multiplayer(multiplayer::Screen::Disconnected { retry, .. }) =
&mut title.mode
{
log::info!(
"Horizon: reconnecting to {} in {} s",
net::shown_address(&plan.address),
plan.seconds_left(std::time::Instant::now())
);
*retry = Some(plan);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_server_is_forgotten_when_left_or_lost_without_a_plan() {
let mut last = LastServer::default();
last.connecting("example.org", "Player", true);
assert_eq!((last.address(), last.lan()), (Some("example.org"), true));
// No hook plans an attempt in this test process.
assert_eq!(last.ended("Timed out", None, false), None);
assert_eq!((last.address(), last.lan()), (None, false));
last.connecting("example.org:25566", "Player", false);
last.left();
assert_eq!(last.address(), None);
}
}
+3 -3
View File
@@ -140,8 +140,8 @@ pub enum Screen {
},
Disconnected {
reason: String,
/// Auto-reconnect's next attempt, until it is cancelled.
retry: Option<crate::reconnect::Retry>,
/// The next attempt a hook planned (`last_server`), until it is cancelled.
retry: Option<horizon_hooks::Reconnect>,
/// Wrapped reason lines, set by the last draw; the buttons sit below them.
lines: std::cell::Cell<usize>,
},
@@ -1970,7 +1970,7 @@ impl Screen {
}
}
/// Auto-reconnect's attempt, once its countdown is over.
pub fn due_retry(&mut self) -> Option<crate::reconnect::Retry> {
pub fn due_retry(&mut self) -> Option<horizon_hooks::Reconnect> {
let Self::Disconnected { retry, .. } = self else {
return None;
};
+97 -36
View File
@@ -2,8 +2,18 @@
//! connection may be retried, and when. Only lost connections and the
//! server's own shutdown or restart are retried, never kicks, bans or
//! refusals; attempts back off and stop after a few.
//!
//! It runs on the engine's connection hooks ([`HOOKS`], `horizon_hooks`): the engine
//! reports a connection's start, join, leave and end, and shows and runs the attempt
//! this plans. Nothing else in the engine reads it, so it leaves for a Horizon crate
//! as a file move once its setting can leave too (docs/layers.md, plan M5).
use horizon_hooks::{Ended, Hooks, Server, seat};
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// A planned attempt.
pub use horizon_hooks::Reconnect as Retry;
/// Seconds before each attempt; there are no more after the last.
pub const DELAYS: [u64; 5] = [5, 10, 20, 40, 60];
/// Staying connected this long counts the next drop as a fresh one.
@@ -111,43 +121,7 @@ pub struct Tracker {
attempts: usize,
joined: Option<Instant>,
}
/// A planned attempt.
#[derive(Clone, Debug, PartialEq)]
pub struct Retry {
pub address: String,
pub name: String,
pub lan: bool,
pub at: Instant,
/// 1-based, of `DELAYS.len()`.
pub attempt: usize,
}
impl Retry {
/// Whole seconds left, rounded up.
pub fn seconds_left(&self, now: Instant) -> u64 {
let left = self.at.saturating_duration_since(now);
left.as_secs() + u64::from(left.subsec_nanos() > 0)
}
pub fn due(&self, now: Instant) -> bool {
now >= self.at
}
pub fn text(&self, now: Instant) -> String {
format!(
"Reconnecting in {} s (attempt {} of {})",
self.seconds_left(now),
self.attempt,
DELAYS.len()
)
}
}
impl Tracker {
/// The server connected to last (splitscreen players join the same one).
pub fn address(&self) -> Option<&str> {
self.address.as_deref()
}
/// That server was found on the local network.
pub fn lan(&self) -> bool {
self.lan
}
/// A connection to `address` as `name` starts: a new address starts
/// counting anew.
pub fn connecting(&mut self, address: &str, name: &str, lan: bool) {
@@ -196,6 +170,65 @@ impl Tracker {
lan: self.lan,
at: now + delay,
attempt: self.attempts,
attempts: DELAYS.len(),
})
}
}
/// Auto-reconnect on the engine's connection hooks: registered at startup with the other
/// extras (`entry.rs`); it plans nothing while its setting is off. Splitscreen players share
/// the hooks, so each seat (`horizon_hooks::seat`) has its own tracker and setting, as each
/// player's game had before.
pub static HOOKS: AutoReconnect = AutoReconnect {
seats: Mutex::new(Vec::new()),
};
pub struct AutoReconnect {
seats: Mutex<Vec<Seat>>,
}
#[derive(Default)]
struct Seat {
tracker: Tracker,
enabled: bool,
}
impl AutoReconnect {
/// The `autoReconnect` setting of the player whose turn it is.
pub fn set_enabled(&self, on: bool) {
self.with(|s| s.enabled = on);
}
fn with<T>(&self, f: impl FnOnce(&mut Seat) -> T) -> T {
let mut seats = self.seats.lock().unwrap_or_else(|e| e.into_inner());
let i = seat();
if seats.len() <= i {
seats.resize_with(i + 1, Seat::default);
}
f(&mut seats[i])
}
}
impl Hooks for AutoReconnect {
fn connecting(&self, server: &Server<'_>) {
self.with(|s| {
s.tracker
.connecting(server.address, server.name, server.lan)
});
}
fn joined(&self) {
self.with(|s| s.tracker.joined(Instant::now()));
}
fn left(&self) {
self.with(|s| s.tracker.reset());
}
fn ended(&self, end: &Ended<'_>) -> Option<Retry> {
self.with(|s| {
if !s.enabled {
s.tracker.reset();
return None;
}
s.tracker
.ended(end.reason, end.key, end.from_server, Instant::now())
})
}
}
@@ -458,6 +491,33 @@ mod tests {
);
}
#[test]
fn each_splitscreen_seat_keeps_its_own_attempts_and_setting() {
use horizon_hooks::engine::set_seat;
let hooks = AutoReconnect {
seats: Mutex::new(Vec::new()),
};
let lost = Ended::new("Timed out", Some("disconnect.timeout"), false);
for (seat, on) in [(0, false), (1, true)] {
set_seat(seat);
hooks.set_enabled(on);
hooks.connecting(&Server::new("example.org", "Player", false));
}
// P2 has it on: an attempt in 5 s. P1 has it off: none, and P2's state stays.
set_seat(1);
assert_eq!(hooks.ended(&lost).map(|r| r.attempt), Some(1));
set_seat(0);
assert_eq!(hooks.ended(&lost), None);
set_seat(1);
hooks.connecting(&Server::new("example.org", "Player", false));
assert_eq!(hooks.ended(&lost).map(|r| r.attempt), Some(2));
// P2 leaving on purpose starts it over.
hooks.left();
hooks.connecting(&Server::new("example.org", "Player", false));
assert_eq!(hooks.ended(&lost).map(|r| r.attempt), Some(1));
set_seat(0);
}
#[test]
fn the_countdown_rounds_up() {
let now = Instant::now();
@@ -467,6 +527,7 @@ mod tests {
lan: false,
at: now + Duration::from_millis(4200),
attempt: 2,
attempts: DELAYS.len(),
};
assert_eq!(r.seconds_left(now), 5);
assert_eq!(r.text(now), "Reconnecting in 5 s (attempt 2 of 5)");
+1 -1
View File
@@ -4707,7 +4707,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
}
}
/// The server auto-reconnect retries now, once its countdown is over.
pub fn due_retry(&mut self) -> Option<crate::reconnect::Retry> {
pub fn due_retry(&mut self) -> Option<horizon_hooks::Reconnect> {
match &mut self.mode {
Mode::Multiplayer(screen) => screen.due_retry(),
_ => None,
+93
View File
@@ -0,0 +1,93 @@
//! Container screens (docs/layers.md, "Container screens"): input and drawing on an open container
//! screen (the inventory, chests, furnaces, crafting tables...) for a crate above the engine, as
//! Mouse Tweaks and sorting need. The engine implements [`Container`] for the length of one call
//! of a hook (`Hooks::container_press` and the others); everything in it is plain data.
/// A slot of the open screen: opaque, stable while the screen is open.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct SlotId(pub u32);
/// What a slot is for.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
/// The player's hotbar.
Hotbar,
/// The player's main inventory, above the hotbar.
Inventory,
/// A slot that only holds items: a chest's, a hopper's, a mount's chest.
Storage,
/// A slot a recipe or a furnace reads: a crafting grid's, a furnace's input and fuel.
Input,
/// A slot that only gives: a crafting or furnace result.
Result,
/// The player's armour and off hand, a mount's saddle and armour.
Equipment,
Other,
}
/// A slot drawn on the screen.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Slot {
pub id: SlotId,
pub role: Role,
/// Its top left corner in GUI pixels, from the panel's.
pub x: f32,
pub y: f32,
}
/// The items in a slot or on the cursor.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stack {
/// The item's id (`minecraft:stone`).
pub item: String,
pub count: u32,
/// How many one slot holds of it.
pub max: u32,
/// Equal for stacks of the same item with the same components: those merge.
pub kind: u64,
}
/// One ordinary slot click, as vanilla's screen sends it.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Click {
/// Picks up, puts down or swaps (`PICKUP`), the left or right button.
Pick { right: bool },
/// The Shift click (`QUICK_MOVE`).
QuickMove,
/// Puts the cursor's stack, or one of it, in the slot as a one-slot drag (`QUICK_CRAFT`): a
/// server whose cursor is already empty ignores it, where a click would pick up the slot.
Put { one: bool },
}
/// The open screen's menu, for the length of one call. The engine implements it. The reads that
/// ask the menu's rules take `&mut self`, as the engine's do; drawing gets the others only.
pub trait Container {
/// Its menu type: `inventory` for the player's own, else the registry name the server opened
/// (`minecraft:generic_9x3`, `minecraft:furnace`...).
fn menu(&self) -> &str;
/// Every slot drawn, in the screen's order, in every menu: a crate keeps to the menus it acts
/// in, by [`Container::menu`] (Mouse Tweaks stays out of those whose slots cost or trigger
/// something: anvils, enchanting, merchants, beacons, crafters). Creative's tabs are no
/// container screen.
fn slots(&self) -> Vec<Slot>;
fn stack(&mut self, slot: SlotId) -> Option<Stack>;
/// The stack on the cursor.
fn carried(&self) -> Option<Stack>;
/// Whether `slot` takes one more of `of`, by the menu's rules (`mayPlace`, the slot's limit).
fn takes_one(&mut self, slot: SlotId, of: &Stack) -> bool;
/// Where a Shift click on `slot` would put its items (`quickMoveStack`), first first.
fn landing(&mut self, slot: SlotId) -> Vec<SlotId>;
/// One ordinary click. False when nothing took it.
fn click(&mut self, slot: SlotId, click: Click) -> bool;
/// How many slots vanilla's drag of the cursor's stack spreads over now (0 while none runs).
fn spread(&self) -> usize;
/// The cursor, in GUI pixels from the panel's corner.
fn cursor(&self) -> (f32, f32);
/// Whether Shift is held.
fn shift(&self) -> bool;
/// The creative tabs' order of the stack in `slot` (`u32::MAX` when no tab lists it) and the
/// name it shows: how sorting ranks it.
fn order(&mut self, slot: SlotId) -> (u32, String);
}
+309 -1
View File
@@ -17,13 +17,18 @@
//! pages, [`hud`] for what it draws on the HUD.
#![forbid(unsafe_code)]
pub mod containers;
pub mod hud;
pub mod look;
pub mod pages;
mod registry;
pub mod settings;
use containers::{Container, SlotId};
use registry::Registry;
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
/// What a crate above the engine hears from the game. Every method has an empty default:
/// implement only those you need. Hooks run on the game's thread (the render thread) unless a
@@ -40,6 +45,49 @@ pub trait Hooks: Sync {
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
fn shutdown(&self) {}
/// This window starts connecting to a multiplayer server (the server list, a direct
/// connection, a Realm, a LAN world). Not single player's own server.
fn connecting(&self, _server: &Server<'_>) {}
/// The server let the player in.
fn joined(&self) {}
/// The player left the server on purpose: nothing to retry.
fn left(&self) {}
/// The connection ended without the player leaving it (lost, closed, kicked, refused). A hook
/// may plan another attempt: the engine shows its countdown on the Disconnected screen, with
/// Cancel, and connects again when it is due. Every hook hears the end; the first plan wins.
fn ended(&self, _end: &Ended<'_>) -> Option<Reconnect> {
None
}
// An open container screen (`containers`): each input hook returns whether it took the
// input, and then vanilla's handling of it doesn't run. The first hook that takes it wins.
/// A mouse button went down over the panel, on `slot` or none.
fn container_press(&self, _c: &mut dyn Container, _slot: Option<SlotId>, _right: bool) -> bool {
false
}
/// The cursor moved with a button held, now over `slot` or none.
fn container_drag(&self, _c: &mut dyn Container, _slot: Option<SlotId>) -> bool {
false
}
/// The button went up. Every hook hears it.
fn container_release(&self, _c: &mut dyn Container) {}
/// The wheel turned `steps` notches over `slot` or none (up is positive; a trackpad turns it
/// in fractions).
fn container_wheel(&self, _c: &mut dyn Container, _slot: Option<SlotId>, _steps: f32) -> bool {
false
}
/// A key or a mouse button the screen got, by its name (`key.keyboard.r`, `key.mouse.middle`),
/// with the cursor over `slot` or none.
fn container_key(&self, _c: &mut dyn Container, _slot: Option<SlotId>, _key: &str) -> bool {
false
}
/// Draws over the panel, from its top left corner, after the slots and before the tooltip.
fn container_draw(&self, _c: &dyn Container, _canvas: &mut dyn hud::Canvas) {}
}
/// What [`Hooks::started`] gets.
@@ -55,6 +103,77 @@ impl<'a> Started<'a> {
}
}
/// What [`Hooks::connecting`] gets.
#[non_exhaustive]
pub struct Server<'a> {
/// As the player gave it (`host[:port]`).
pub address: &'a str,
/// The player name the connection uses.
pub name: &'a str,
/// Found on the local network.
pub lan: bool,
}
impl<'a> Server<'a> {
pub fn new(address: &'a str, name: &'a str, lan: bool) -> Self {
Self { address, name, lan }
}
}
/// What [`Hooks::ended`] gets.
#[non_exhaustive]
pub struct Ended<'a> {
/// The reason shown on the Disconnected screen.
pub reason: &'a str,
/// Its translation key, when it has one (`disconnect.timeout`, `multiplayer.disconnect.kicked`).
pub key: Option<&'a str>,
/// The server sent it (a disconnect packet), rather than the connection failing.
pub from_server: bool,
}
impl<'a> Ended<'a> {
pub fn new(reason: &'a str, key: Option<&'a str>, from_server: bool) -> Self {
Self {
reason,
key,
from_server,
}
}
}
/// Another attempt at a server, planned by [`Hooks::ended`].
#[derive(Clone, Debug, PartialEq)]
pub struct Reconnect {
pub address: String,
pub name: String,
pub lan: bool,
/// When to connect.
pub at: Instant,
/// This attempt, 1-based, of `attempts`.
pub attempt: usize,
pub attempts: usize,
}
impl Reconnect {
/// Whole seconds left, rounded up.
pub fn seconds_left(&self, now: Instant) -> u64 {
let left = self.at.saturating_duration_since(now);
left.as_secs() + u64::from(left.subsec_nanos() > 0)
}
pub fn due(&self, now: Instant) -> bool {
now >= self.at
}
/// The Disconnected screen's countdown line.
pub fn text(&self, now: Instant) -> String {
format!(
"Reconnecting in {} s (attempt {} of {})",
self.seconds_left(now),
self.attempt,
self.attempts
)
}
}
static HOOKS: Registry<dyn Hooks> = Registry::new();
/// Why [`register`] refused a hook.
@@ -81,10 +200,27 @@ fn all() -> &'static [&'static dyn Hooks] {
HOOKS.all()
}
thread_local! {
static SEAT: Cell<usize> = const { Cell::new(0) };
}
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
pub fn seat() -> usize {
SEAT.get()
}
/// The engine's side: where it calls the hooks. Only the engine calls these.
pub mod engine {
use super::*;
/// The player whose turn starts on this thread ([`seat`]).
pub fn set_seat(seat: usize) {
SEAT.set(seat);
}
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
/// registration ends.
pub fn seal() {
@@ -121,6 +257,79 @@ pub mod engine {
hooks.shutdown();
}
}
/// [`Hooks::connecting`].
pub fn connecting(server: &Server<'_>) {
for hooks in all() {
hooks.connecting(server);
}
}
/// [`Hooks::joined`].
pub fn joined() {
for hooks in all() {
hooks.joined();
}
}
/// [`Hooks::left`].
pub fn left() {
for hooks in all() {
hooks.left();
}
}
/// Whether a container screen's input reaches a hook: false while none is registered, and
/// the engine builds no `Container` then.
pub fn containers() -> bool {
!all().is_empty()
}
/// [`Hooks::container_press`]: true when a hook took it.
pub fn container_press(c: &mut dyn Container, slot: Option<SlotId>, right: bool) -> bool {
all().iter().any(|h| h.container_press(c, slot, right))
}
/// [`Hooks::container_drag`].
pub fn container_drag(c: &mut dyn Container, slot: Option<SlotId>) -> bool {
all().iter().any(|h| h.container_drag(c, slot))
}
/// [`Hooks::container_release`], to every hook.
pub fn container_release(c: &mut dyn Container) {
for hooks in all() {
hooks.container_release(c);
}
}
/// [`Hooks::container_wheel`].
pub fn container_wheel(c: &mut dyn Container, slot: Option<SlotId>, steps: f32) -> bool {
all().iter().any(|h| h.container_wheel(c, slot, steps))
}
/// [`Hooks::container_key`].
pub fn container_key(c: &mut dyn Container, slot: Option<SlotId>, key: &str) -> bool {
all().iter().any(|h| h.container_key(c, slot, key))
}
/// [`Hooks::container_draw`].
pub fn container_draw(c: &dyn Container, canvas: &mut dyn crate::hud::Canvas) {
for hooks in all() {
hooks.container_draw(c, canvas);
}
}
/// [`Hooks::ended`]: every hook hears it, and the first plan is the one kept.
pub fn ended(end: &Ended<'_>) -> Option<Reconnect> {
let mut plan = None;
for hooks in all() {
let mine = hooks.ended(end);
if plan.is_none() {
plan = mine;
}
}
plan
}
}
#[cfg(test)]
@@ -142,27 +351,126 @@ mod tests {
fn published(&self, open: bool) {
self.0.lock().unwrap().push(format!("published {open}"));
}
fn ended(&self, end: &Ended<'_>) -> Option<Reconnect> {
self.0.lock().unwrap().push(format!("ended {}", end.reason));
None
}
}
/// Plans an attempt numbered by its field.
struct Planner(usize);
impl Hooks for Planner {
fn ended(&self, end: &Ended<'_>) -> Option<Reconnect> {
Some(Reconnect {
address: end.reason.to_owned(),
name: String::new(),
lan: false,
at: Instant::now(),
attempt: self.0,
attempts: 5,
})
}
}
static FIRST: Planner = Planner(1);
static SECOND: Planner = Planner(2);
/// Implements nothing: the defaults do nothing.
struct Silent;
impl Hooks for Silent {}
static RECORDER: Recorder = Recorder(StdMutex::new(Vec::new()));
/// Takes its key on a container screen; notes what reached it.
struct Keys(&'static str, StdMutex<Vec<String>>);
impl Hooks for Keys {
fn container_key(&self, _: &mut dyn Container, _: Option<SlotId>, key: &str) -> bool {
self.1.lock().unwrap().push(format!("key {key}"));
key == self.0
}
fn container_release(&self, _: &mut dyn Container) {
self.1.lock().unwrap().push("release".into());
}
}
static SORT: Keys = Keys("key.keyboard.r", StdMutex::new(Vec::new()));
static ALSO_R: Keys = Keys("key.keyboard.r", StdMutex::new(Vec::new()));
/// A screen with nothing in it.
struct Empty;
impl Container for Empty {
fn menu(&self) -> &str {
"inventory"
}
fn slots(&self) -> Vec<containers::Slot> {
Vec::new()
}
fn stack(&mut self, _: SlotId) -> Option<containers::Stack> {
None
}
fn carried(&self) -> Option<containers::Stack> {
None
}
fn takes_one(&mut self, _: SlotId, _: &containers::Stack) -> bool {
false
}
fn landing(&mut self, _: SlotId) -> Vec<SlotId> {
Vec::new()
}
fn click(&mut self, _: SlotId, _: containers::Click) -> bool {
false
}
fn spread(&self) -> usize {
0
}
fn cursor(&self) -> (f32, f32) {
(0.0, 0.0)
}
fn shift(&self) -> bool {
false
}
fn order(&mut self, _: SlotId) -> (u32, String) {
(u32::MAX, String::new())
}
}
#[test]
fn registered_hooks_hear_the_game_and_late_ones_are_refused() {
register(&RECORDER).unwrap();
register(&Silent).unwrap();
register(&FIRST).unwrap();
register(&SECOND).unwrap();
register(&SORT).unwrap();
register(&ALSO_R).unwrap();
engine::started(&Started::new("0.2.31"));
// Changes only: the same state every frame is said once.
for open in [false, true, true, true, false, false] {
engine::published(open);
}
// Every hook hears the end; the first plan registered is the one kept.
let plan = engine::ended(&Ended::new("lost", None, false)).unwrap();
assert_eq!(plan.attempt, 1);
// A container screen's key goes to the first hook that takes it, its release to all.
assert!(engine::containers());
assert!(!engine::container_key(&mut Empty, None, "key.keyboard.e"));
assert!(engine::container_key(&mut Empty, None, "key.keyboard.r"));
engine::container_release(&mut Empty);
assert_eq!(
*SORT.1.lock().unwrap(),
["key key.keyboard.e", "key key.keyboard.r", "release"]
);
assert_eq!(
*ALSO_R.1.lock().unwrap(),
["key key.keyboard.e", "release"],
"taken before it"
);
engine::shutdown();
assert_eq!(
*RECORDER.0.lock().unwrap(),
["started 0.2.31", "published true", "published false"]
[
"started 0.2.31",
"published true",
"published false",
"ended lost"
]
);
assert_eq!(register(&Silent), Err(TooLate));
}
+178
View File
@@ -0,0 +1,178 @@
//! How the engine draws a few things a crate above may change (docs/layers.md, plan M5): PvP &
//! HUD's look today. Every field's default is vanilla's, so with nothing set the game looks and
//! sounds as vanilla does.
//!
//! Plain data, pushed: a crate changes its own fields of the look of the player whose turn it is
//! ([`update`]; splitscreen players each have theirs: [`crate::seat`]) when its settings load or
//! change, and the engine reads it where it draws ([`engine::get`]). Several crates share the
//! look, each keeping to its fields. Nothing is asked of a crate per frame.
use std::sync::Mutex;
/// What the engine reads. Built from `Look::default()`, then its fields set.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Look {
/// 1.8.9's pictures of a fight: the sword block, the flat item pose and the hand swing.
pub legacy_pose: bool,
/// No attack indicator under the crosshair or beside the hotbar.
pub hide_attack_indicator: bool,
/// The `entity.player.attack.*` sounds a server sends are left out (1.9 added them with the
/// attack cooldown; 1.8.9 played none on a hit).
pub mute_attack_sounds: bool,
/// The sweep and damage-heart particles a server sends are left out (1.9 added them).
pub hide_attack_particles: bool,
/// The block outline, hurt overlay and glint colours the renderer draws with.
pub frame: Frame,
/// The first-person shield's size, in percent.
pub shield_size: u8,
/// The totem animation's scale.
pub totem_scale: f32,
/// How far down the first-person fire overlay moves, as a fraction of its height.
pub fire_lower: f32,
/// Lava's fog as with Fire Resistance, without the effect.
pub clear_lava: bool,
/// Water's fog as after 30 seconds under water, from the first moment.
pub clear_water: bool,
pub chat: Chat,
/// The status effect icons at the top right are left out (a crate shows the effects itself).
pub hide_effect_icons: bool,
/// The time of day this player sees, as `/time set` takes it (0 to 23999); `None` for the
/// world's. Only what is drawn follows it, never what plays by the world's time.
pub time_of_day: Option<i64>,
/// The rain and thunder levels this player sees (0 to 1); `None` for the world's.
pub weather: Option<(f32, f32)>,
}
impl Default for Look {
fn default() -> Self {
Self {
legacy_pose: false,
hide_attack_indicator: false,
mute_attack_sounds: false,
hide_attack_particles: false,
frame: Frame::default(),
shield_size: 100,
totem_scale: 1.0,
fire_lower: 0.0,
clear_lava: false,
clear_water: false,
chat: Chat::default(),
hide_effect_icons: false,
time_of_day: None,
weather: None,
}
}
}
impl Look {
/// Whether the sound `name` a server sent is left out ([`Look::mute_attack_sounds`]).
pub fn mutes_sound(&self, name: &str) -> bool {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
self.mute_attack_sounds && name.starts_with("entity.player.attack.")
}
/// The day time drawn while the world's is `world`: the same day (the moon's phase follows the
/// world's), at [`Look::time_of_day`].
pub fn day_time(&self, world: i64) -> i64 {
match self.time_of_day {
Some(t) => world.div_euclid(24000) * 24000 + t,
None => world,
}
}
}
/// What the renderer reads each frame.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Frame {
/// The block outline's colour (`0xAARRGGBB`) and its stroke's scale; `None` for vanilla's.
pub outline: Option<(u32, f32)>,
/// The hurt overlay's texel, 0 for vanilla's.
pub hurt_texel: u32,
/// The enchantment glint's texel (RGB bytes then 0xFF, `unpack4x8unorm`'s order), 0 for
/// vanilla's.
pub glint: u32,
}
/// The chat's extras. Each is off in vanilla.
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Chat {
/// The local time before each message.
pub timestamps: bool,
/// A message repeated in a row shows once with its count.
pub compact: bool,
/// A click on a message (where it has no click action) copies it.
pub copy: bool,
/// Ctrl+F in the chat searches its messages.
pub search: bool,
}
/// Each seat's look; a seat that never set one has vanilla's.
static LOOKS: Mutex<Vec<Look>> = Mutex::new(Vec::new());
/// Changes the look of the player whose turn it is: `change` sets the fields its crate owns and
/// leaves the others' be.
pub fn update(change: impl FnOnce(&mut Look)) {
let mut looks = LOOKS.lock().unwrap_or_else(|e| e.into_inner());
let seat = crate::seat();
if looks.len() <= seat {
looks.resize(seat + 1, Look::default());
}
change(&mut looks[seat]);
}
/// The engine's side. Only the engine calls it.
pub mod engine {
use super::*;
/// The look of the player whose turn it is: vanilla's until something set one.
pub fn get() -> Look {
let looks = LOOKS.lock().unwrap_or_else(|e| e.into_inner());
looks.get(crate::seat()).copied().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_seat_sees_its_own_look_and_vanillas_until_set() {
// Seats no other test plays, as the looks are shared.
crate::engine::set_seat(5);
assert_eq!(engine::get(), Look::default());
update(|look| look.legacy_pose = true);
// Another crate's change keeps the first one's.
update(|look| look.clear_water = true);
let look = engine::get();
assert!(look.legacy_pose && look.clear_water);
crate::engine::set_seat(4);
assert_eq!(engine::get(), Look::default());
crate::engine::set_seat(0);
}
#[test]
fn attack_sounds_go_only_when_muted() {
let mut look = Look::default();
let attacks = [
"minecraft:entity.player.attack.strong",
"entity.player.attack.sweep",
"minecraft:entity.player.attack.nodamage",
];
assert!(!attacks.iter().any(|name| look.mutes_sound(name)));
look.mute_attack_sounds = true;
assert!(attacks.iter().all(|name| look.mutes_sound(name)));
// 1.8.9 had the hurt sound: it stays.
assert!(!look.mutes_sound("minecraft:entity.player.hurt"));
}
#[test]
fn a_fixed_time_keeps_the_worlds_day() {
// Day 9 at 5000: the moon's phase (the day number) stays, the time of day is the chosen.
let world = 9 * 24000 + 5000;
let mut look = Look::default();
assert_eq!(look.day_time(world), world);
look.time_of_day = Some(18000);
assert_eq!(look.day_time(world), 9 * 24000 + 18000);
look.time_of_day = Some(6000);
assert_eq!(look.day_time(-100), -24000 + 6000);
}
}
+13 -1
View File
@@ -46,7 +46,7 @@ pub use horizon_server_files as server_files;
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{Hooks, Started, TooLate, register};
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
@@ -60,6 +60,18 @@ pub mod pages {
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, register};
}
/// How the game draws a few things a crate may change, per player (`horizon_hooks::look`):
/// vanilla's until set.
pub mod look {
pub use horizon_hooks::look::{Chat, Frame, Look, update};
}
/// Input and drawing on an open container screen (docs/layers.md, "Container screens"): the
/// `container_*` methods of [`hooks::Hooks`] get the screen as a [`containers::Container`].
pub mod containers {
pub use horizon_hooks::containers::{Click, Container, Role, Slot, SlotId, Stack};
}
/// Drawing on the HUD (docs/layers.md, "Platform registries").
pub mod hud {
pub use horizon_hooks::hud::{Canvas, Corner, Layer, register};
+3 -3
View File
@@ -14,9 +14,9 @@ again restores vanilla's behaviour completely.
| Dynamic lights: item glow, nearest sources | `crates/horizon-client-render/src/dynamic_light.rs` (unit tested), `game/dynamic_lights.rs`, `terrain.wgsl` and `entity.wgsl` (`dynamic_block_light`) |
| HUD drawing (a native mod's minimap, corner stacking) | `crates/horizon-client/src/extras_hud.rs` |
| Minimap colours, tiles and image (for the Minimap native mod) | `crates/horizon-client-render/src/minimap.rs` |
| Reconnect policy and backoff | `crates/horizon-client/src/reconnect.rs` |
| Game glue (input hooks, per-frame update) | `crates/horizon-client/src/game/extras.rs` |
| Mouse Tweaks-style controls, sort buttons | `crates/horizon-client/src/game/tweaks.rs` |
| Reconnect policy and backoff | `crates/horizon-client/src/reconnect.rs`, a hook on the engine's connection lifecycle (`horizon_hooks`: `connecting`, `joined`, `left`, `ended`), registered in `entry.rs`; the engine shows and runs the attempt it plans (`last_server.rs`, `game/world_session.rs`); `game/extras/reconnect.rs` hands it the setting |
| Game glue (input hooks, per-frame update) | `crates/horizon-client/src/game/extras.rs`; each extra's own glue in a child module, `game/extras/` |
| Mouse Tweaks-style controls, sort buttons | `crates/horizon-client/src/game/extras/mouse_tweaks.rs`, `game/extras/sorting.rs` |
| Sort order and its clicks | `crates/horizon-client/src/inv_sort.rs` (pure, unit tested) |
| The music page's rows, and the pools it filters | `options_ui.rs` (`Page::Music`), `crates/horizon-client/src/music.rs` (`track_groups`, `MusicManager::set_off`), `sound_events.rs` (`sound_except`) |
| Cosmetics (show mine, show others'), off with their flag | `extras.rs`, `crates/horizon-client/src/game/cosmetics.rs` ([cosmetics.md](cosmetics.md)) |
+1 -1
View File
@@ -23,7 +23,7 @@ What changes in that build:
|---|---|---|
| Cosmetics, emotes and the emote wheel, "Show me as a Horizon player" | `game/cosmetics.rs` (+ `cosmetics/fx.rs`, `cosmetics/emotes.rs`, `cosmetics/emote_wheel.rs`), crate `horizon-cosmetics` | `game::cosmetics::enabled()`: the service is never asked, nothing is drawn, B opens no wheel |
| The official server added to the server list | `multiplayer.rs` (`add_official_server`) | their call at startup (`game.rs`, `fn run`) |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs` and `game/extras/`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
| Play with Friends | `game/friends.rs`, `friends_screen.rs`, crate `horizon-friends` | the `friends` feature (`game/friends_off.rs` stands in) |
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
+7 -3
View File
@@ -157,7 +157,7 @@ Horizon's own extras and its product, and every built-in address of an outside s
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
`horizon-official`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `multiplayer.rs` (`add_official_server`), `extras_ui.rs`,
`game/discord.rs`, `game/extras.rs` and `game/extras/`, `multiplayer.rs` (`add_official_server`), `extras_ui.rs`,
`pvp.rs`.
- **Rules:**
- It uses the platform's public API only, never the engine.
@@ -287,8 +287,12 @@ otherwise, and must return quickly. With nothing registered a dispatch is one lo
build pays nothing.
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
`published` (the world opened to or closed from LAN or friends, on change) and `shutdown` (the window closed, the
world saved). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
world saved), and a multiplayer connection's lifecycle: `connecting` (address, player name, LAN), `joined`, `left` (on
purpose) and `ended` (reason, its translation key, whether the server sent it). `ended` may return a `Reconnect`
(address, name, LAN, when, attempt of attempts): the engine shows its countdown on the Disconnected screen, with
Cancel, and connects when it is due; every hook hears the end and the first plan wins. Auto-reconnect is built on
them (`reconnect.rs`); the engine keeps the last server itself (`last_server.rs`). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
+4 -3
View File
@@ -21,7 +21,8 @@ Each feature is built from what it visibly does, never from another client's or
|---|---|
| Settings, parsing, the page's rows | `crates/horizon-client-render/src/pvp.rs` (stored in `horizon-extras.txt`, see [extras.md](extras.md)) |
| The page | `options_ui.rs` (`Page::Pvp`), the rows of `pvp::rows()` drawn like the Extras page |
| Game glue | `crates/horizon-client/src/game/pvp.rs` |
| Game glue | `crates/horizon-client/src/game/extras/pvp.rs` |
| What the engine draws differently | `horizon_hooks::look` (`Look`, PvP & HUD's fields set each frame from the options by `Pvp::apply`); the engine reads it only in `game/look.rs` |
## Crosshair (`crosshair`, default off)
@@ -53,7 +54,7 @@ Each feature is built from what it visibly does, never from another client's or
Boxes in the screen corners, placed like the minimap and mods' boxes (`extras_hud::Corners`), each in a corner of
its own, with an optional dark background (on by default). F3's debug screen takes the top corners as it does from
mods' boxes. Code: `crates/horizon-client-render/src/pvp_hud.rs` (lines, cache, layout) and `game/pvp.rs`.
mods' boxes. Code: `crates/horizon-client-render/src/pvp_hud.rs` (lines, cache, layout) and `game/extras/pvp.rs`.
- **Armour** (bottom right): each piece worn, head first, with the uses left as a number or a percent, coloured
like the item's durability bar (green to red). Only what the inventory already shows.
@@ -136,7 +137,7 @@ There is no "no hurt camera" option: vanilla's Accessibility → Damage Tilt alr
The time of day (morning, noon, sunset, midnight) and the weather (clear, rain, thunder) this player sees. Only
the frame changes: the sky's colours, sun, moon and stars, the sky's light in the lightmap, the rain and its
fog (`Game::view_day_time`, `Game::view_weather` in `game/pvp.rs`). The world's `day_time` and weather, and all
fog (`Game::view_day_time`, `Game::view_weather` in `game/look.rs`). The world's `day_time` and weather, and all
that plays by them (mobs, crops, beds, riptide, rain sounds), stay the server's, and nothing is sent to it.
The moon keeps the world's phase. Only a client of a server draws it (single player's window is one), in the
Overworld.