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
183 changed files with 4779 additions and 6274 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
Generated
+7 -3
View File
@@ -1418,7 +1418,6 @@ dependencies = [
name = "horizon-chat-filter"
version = "0.1.0"
dependencies = [
"horizon-server-files",
"serde",
"serde_json",
"toml",
@@ -1529,6 +1528,7 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1592,6 +1592,7 @@ dependencies = [
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-render-pipeline",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
@@ -1667,7 +1668,6 @@ dependencies = [
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"horizon-platform",
"if-addrs",
"serde",
"serde_json",
@@ -1793,7 +1793,6 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-platform",
@@ -1892,6 +1891,10 @@ dependencies = [
"ring",
]
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
[[package]]
name = "horizon-server"
version = "0.1.0"
@@ -1954,6 +1957,7 @@ version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"horizon-render-pipeline",
"naga",
"png",
"zip",
+1
View File
@@ -43,6 +43,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-render-pipeline",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
-2
View File
@@ -7,8 +7,6 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The moderation durations, shared with `/tempban`.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
+55 -3
View File
@@ -10,9 +10,6 @@ use std::path::{Path, PathBuf};
mod normalise;
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
@@ -477,6 +474,61 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(60 * MINUTE, DurationUnit::Hour)
} else {
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
-33
View File
@@ -670,39 +670,6 @@ pub fn with_horizon_icon(
attributes
}
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Display {
pub width: u32,
pub height: u32,
pub refresh_hz: Option<u32>,
pub scale: f64,
}
/// The screen `window` is on, else the primary one, else the only one: right after the window
/// is made, Wayland doesn't say yet which screen it is on.
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
let only = || {
let mut all = window.available_monitors();
all.next().filter(|_| all.next().is_none())
};
window
.current_monitor()
.or_else(|| window.primary_monitor())
.or_else(only)
}
/// [`monitor`]'s size, refresh rate and scale.
pub fn display(window: &winit::window::Window) -> Option<Display> {
monitor(window).map(|m| Display {
width: m.size().width,
height: m.size().height,
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
scale: m.scale_factor(),
})
}
#[cfg(test)]
mod tests {
use super::*;
+5 -4
View File
@@ -42,6 +42,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -109,17 +111,16 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "horizon-extras"]
default = ["audio", "controllers", "discord", "friends"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
horizon-extras = ["discord"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
friends = ["dep:horizon-friends"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
+1
View File
@@ -26,6 +26,7 @@ horizon-narrator = { path = "../horizon-narrator" }
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
@@ -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: &[],
@@ -121,8 +121,8 @@ impl CommandBlock {
condition_met: flag("conditionMet", false),
}
}
/// `CommandBlockEntity.saveAdditional`. The last output is the component
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
/// plain string component.
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![
("Command", Tag::String(self.command.as_str().into())),
@@ -135,7 +135,7 @@ impl CommandBlock {
if self.track_output
&& let Some(last) = &self.last_output
{
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
}
out.push((
"UpdateLastExecution",
+204 -76
View File
@@ -1,34 +1,27 @@
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
//! vanilla sky. A pack without layers keeps the vanilla sky.
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
use std::path::Path;
use std::sync::Arc;
use glam::Mat4;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
/// A layer's texture, RGBA8.
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// How a layer mixes with what is drawn below it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Blend {
Add,
Subtract,
Multiply,
Dodge,
Burn,
Screen,
Replace,
Overlay,
Alpha,
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
@@ -41,44 +34,62 @@ pub struct Draw {
pub rotation: Mat4,
}
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Weather {
Clear,
Rain,
Thunder,
}
/// What a frame's layers may depend on.
#[derive(Clone, Copy, Debug)]
pub struct Conditions<'a> {
/// Ticks of the day, counting the days.
pub day_time: i64,
pub weather: Weather,
/// The biome at the camera, namespaced.
pub biome: &'a str,
/// The camera's block height.
pub y: i32,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
/// The world the layers were last drawn for.
pub world: Option<i32>,
/// Each layer's eased visibility, 0 to 1.
pub eased: Vec<f32>,
world: Option<i32>,
eased: Vec<Transition>,
}
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
pub trait SkyLayers: Send + Sync {
/// Every layer's texture: [`Draw::image`] indexes it.
fn images(&self) -> &[Image];
/// Whether `world` has layers.
fn has(&self, world: i32) -> bool;
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
fn draws(
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
&self,
world: i32,
conditions: &Conditions,
@@ -86,30 +97,147 @@ pub trait SkyLayers: Send + Sync {
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
);
}
/// No layers: the vanilla sky.
struct NoLayers;
impl SkyLayers for NoLayers {
fn images(&self) -> &[Image] {
&[]
}
fn has(&self, _: i32) -> bool {
false
}
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
) {
out.clear();
s.world = None;
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
}
}
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
crate::pack_hooks::formats()
.custom_sky(root)
.unwrap_or_else(|| Arc::new(NoLayers))
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
+11 -11
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.
@@ -1329,7 +1329,7 @@ pub struct Renderer {
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// Custom sky layers (`crate::custom_sky`), and the frame's from the game.
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
custom_sky: custom_sky::CustomSky,
custom_sky_draws: Vec<crate::custom_sky::Draw>,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
@@ -5517,11 +5517,11 @@ impl Renderer {
))
});
}
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
}
/// The custom sky layers the next frame draws (`SkyLayers::draws`): the game refills it each
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
/// frame, empty when the world's sky has none.
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
&mut self.custom_sky_draws
@@ -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);
@@ -15718,11 +15718,11 @@ mod shadow_caster_tests {
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_shaders::Shadow {
let shadow = horizon_render_pipeline::Shadow {
distance: 128.0,
near,
far,
..horizon_shaders::Shadow::default()
..horizon_render_pipeline::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
@@ -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: &[],
@@ -1,11 +1,12 @@
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{Blend, Draw, SkyLayers};
use crate::custom_sky::{CustomSky as Pack, Draw};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
@@ -84,76 +85,6 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
}
}
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
/// the top row, `West`, `North`, `East` below.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Face {
Bottom,
Top,
South,
West,
North,
East,
}
impl Face {
const ALL: [Self; 6] = [
Self::Bottom,
Self::Top,
Self::South,
Self::West,
Self::North,
Self::East,
];
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
fn cell(self) -> [f32; 4] {
let (col, row) = match self {
Self::Bottom => (0, 0),
Self::Top => (1, 0),
Self::South => (2, 0),
Self::West => (0, 1),
Self::North => (1, 1),
Self::East => (2, 1),
};
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
[u, v, u + 1.0 / 3.0, v + 0.5]
}
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
/// its cell, the bottom face north at the top (the template's `N` marks).
fn corners(self) -> [[f32; 3]; 4] {
match self {
// Seen looking north: west on the left.
Self::North => [
[-1., 1., -1.],
[1., 1., -1.],
[1., -1., -1.],
[-1., -1., -1.],
],
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
Self::West => [
[-1., 1., 1.],
[-1., 1., -1.],
[-1., -1., -1.],
[-1., -1., 1.],
],
// Looking up with north toward the cell's bottom edge: east on the right.
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
// Looking down with north toward the cell's top edge: east on the right.
Self::Bottom => [
[-1., -1., -1.],
[1., -1., -1.],
[1., -1., 1.],
[-1., -1., 1.],
],
}
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
@@ -325,15 +256,10 @@ impl CustomSky {
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
pack: &dyn SkyLayers,
) {
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images()
.images
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
@@ -487,38 +413,6 @@ impl CustomSky {
mod tests {
use super::*;
#[test]
fn the_grid_holds_each_face_once_and_faces_point_out() {
let mut cells: Vec<_> = Face::ALL
.iter()
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
.collect();
cells.dedup();
assert_eq!(cells.len(), 6);
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
for face in Face::ALL {
let c = face.corners();
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
let n = [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
let centre: [f32; 3] =
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
// Seen from inside, u runs right and v down, so u x v points away from the camera.
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
assert!(dot > 0.0, "{face:?} is mirrored");
}
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
assert_eq!(Face::Top.corners()[2][2], -1.0);
assert_eq!(Face::Top.corners()[3][2], -1.0);
// The bottom cell's top edge (v0) is north.
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
}
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
@@ -6,7 +6,7 @@ struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
@@ -338,7 +338,7 @@ pub struct NativeFrame {
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_shaders::Shadow>,
shadow_camera: Option<horizon_render_pipeline::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -1497,7 +1497,7 @@ pub fn frame_inputs(
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_shaders::Shadow>,
shadow: Option<&horizon_render_pipeline::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
@@ -6,8 +6,8 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_render_pipeline::Shadow;
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -4,7 +4,8 @@
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{BufferObject, CustomImage};
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -18,7 +18,8 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
use horizon_shaders::{Group, Post};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
@@ -892,7 +893,7 @@ fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4]
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_color = horizon_render_pipeline::shadow_color(name);
let shadow_depth = match name {
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
"shadowtex1" | "shadowtex1HW" => Some(1),
@@ -911,7 +912,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
};
}
}
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
// colortex buffers are not this pass's.
return Source::Dummy;
}
@@ -925,7 +926,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1582,7 +1583,7 @@ fn bindings(
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_shaders::buffer_index(&t.name)
|| horizon_render_pipeline::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
@@ -2432,7 +2433,7 @@ impl PackPost {
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
@@ -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: &[],
@@ -10,7 +10,7 @@
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_shaders::{Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
@@ -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: &[],
@@ -3,7 +3,7 @@
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
use horizon_shaders::SHADOW_COLORS;
use horizon_render_pipeline::SHADOW_COLORS;
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
@@ -97,7 +97,11 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
}
impl ShadowTargets {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
pub fn reset_history(
&mut self,
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
@@ -125,7 +129,7 @@ impl ShadowTargets {
/// resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
shadow: &horizon_render_pipeline::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
@@ -1,7 +1,8 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
use horizon_render_pipeline::Kind;
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
@@ -118,12 +119,12 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
let buffer = horizon_render_pipeline::buffer_index(name);
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
@@ -28,7 +28,7 @@
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
use horizon_shaders::Shadow;
use horizon_render_pipeline::Shadow;
use super::Frame;
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
@@ -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: &[],
-2
View File
@@ -89,8 +89,6 @@ pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_browser;
pub mod pack_formats;
pub mod pack_hooks;
pub mod pack_options_ui;
pub mod pack_palette;
pub mod pack_screen;
+24 -10
View File
@@ -339,7 +339,6 @@ impl Opt {
Opt::SharePresence => "options.sharePresence",
Opt::DirectionalAudio => "options.directionalAudio",
Opt::MusicToast => "options.musicToast",
Opt::Page(page, row) => pages::slider_caption(page, row),
}
}
pub(crate) fn kind(self, s: &Settings) -> Kind {
@@ -403,7 +402,6 @@ impl Opt {
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
Opt::Page(page, row) => pages::slider_kind(page, row),
_ => Kind::Cycle,
}
}
@@ -880,7 +878,6 @@ impl Opt {
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
Opt::Page(page, row) => pages::slider_text(page, row),
_ => Text::empty(),
}
}
@@ -955,7 +952,6 @@ impl Opt {
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
Opt::Page(page, row) => pages::slide(page, row, v),
_ => {}
}
if self.affects_preset() {
@@ -1628,6 +1624,8 @@ pub struct OptionsUi {
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
/// (page, row) ([`pages::PAGE_KEY`]).
selected_native_key: Option<(usize, usize)>,
/// The pages crates registered (`horizon_hooks::pages`).
pages: &'static [&'static dyn horizon_hooks::pages::Page],
/// Bindings: the action waiting for a pad button.
pub selected_pad_action: Option<&'static str>,
/// The glyphs menus show (`Glyphs::of`: the setting, else the pad's family, and a Nintendo
@@ -1739,6 +1737,7 @@ impl Default for OptionsUi {
scrollbar_drag_y: None,
selected_key: None,
selected_native_key: None,
pages: horizon_hooks::pages::engine::all(),
selected_pad_action: None,
pad_glyphs: crate::pad_glyph::Glyphs::default(),
pad_driving: false,
@@ -1870,6 +1869,25 @@ fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::
})
.collect()
}
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
Page::Music => "horizon.pages.music.tooltip",
_ => return None,
})
}
/// Rows of an OptionsList: (big?, options) or a header.
enum Row {
Big(Opt),
@@ -1889,17 +1907,14 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
return None;
};
let key = opt.tooltip_key(s);
let Opt::Page(page, row) = opt else {
return lang::has(&key).then(|| tr(&key));
};
return pages::tooltip(*page, *row);
return lang::has(&key).then(|| tr(&key));
};
let mut tip = None;
if let Act::Extra(item) = act {
tip = Some(Text::literal(item.description_text()));
}
if let Act::Push(page) = act
&& let Some(text) = pages::horizon_page_tooltip(*page)
&& let Some(text) = horizon_page_tooltip(*page)
{
tip = Some(tr(text));
}
@@ -1919,7 +1934,6 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
}
match act {
Act::Cycle(opt) if !opt.available() => Some(tr("horizon.options.unavailable")),
Act::PageRow(page, row) => pages::tooltip(*page, *row),
Act::ToggleMod(i) => match s.mods.installed.get(*i) {
Some(installed) => {
let description = installed
@@ -8,88 +8,9 @@ use horizon_hooks::pages as added;
/// `selected_key` while a registered page's key waits (`selected_native_key`: page, row).
pub(super) const PAGE_KEY: &str = "horizon.pageKey";
#[cfg(test)]
thread_local! {
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
const { std::cell::Cell::new(None) };
}
/// The pages crates registered.
fn registered() -> &'static [&'static dyn added::Page] {
#[cfg(test)]
if let Some(pages) = TEST_PAGES.get() {
return pages;
}
added::engine::all()
}
/// A registered page's title (a translation key).
pub(super) fn title(page: usize) -> &'static str {
registered().get(page).map_or("", |p| p.title())
}
/// Row `r` of a registered page, as it is now.
fn row(page: usize, r: usize) -> Option<added::Row> {
let mut rows = Vec::new();
registered().get(page)?.rows(&mut rows);
rows.into_iter().nth(r)
}
/// A registered page's slider ([`Opt::Page`]): its caption (a translation key).
pub(super) fn slider_caption(page: usize, r: usize) -> &'static str {
match row(page, r) {
Some(added::Row::Slider { label, .. }) => label,
_ => "",
}
}
/// A registered page's slider: its position.
pub(super) fn slider_kind(page: usize, r: usize) -> Kind {
match row(page, r) {
Some(added::Row::Slider { value, .. }) => Kind::Slider(value.clamp(0.0, 1.0)),
_ => Kind::Cycle,
}
}
/// A registered page's slider: the value its message shows after the caption.
pub(super) fn slider_text(page: usize, r: usize) -> Text {
match row(page, r) {
Some(added::Row::Slider { text, .. }) => label_text(text),
_ => Text::empty(),
}
}
/// A registered page's slider moved.
pub(super) fn slide(page: usize, r: usize, value: f32) {
if let Some(registered) = registered().get(page) {
registered.act(r, added::Act::Slide(value));
}
}
/// What a registered page's row says on hover.
pub(super) fn tooltip(page: usize, r: usize) -> Option<Text> {
registered().get(page)?.tooltip(r).map(label_text)
}
/// What a button opening one of Horizon's pages says on hover.
pub(super) fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
Page::Music => "horizon.pages.music.tooltip",
_ => return None,
})
added::engine::all().get(page).map_or("", |p| p.title())
}
fn label_text(label: added::Label) -> Text {
@@ -145,7 +66,8 @@ impl OptionsUi {
/// The entries of the pages registered for Horizon Settings, two to a row like the engine's.
fn added_entries(&self, rows: &mut Vec<Row>) {
let mut entries = registered()
let mut entries = self
.pages
.iter()
.enumerate()
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
@@ -157,7 +79,7 @@ impl OptionsUi {
/// A registered page's rows, asked of it each time the page is drawn.
pub(super) fn added_rows(&self, page: usize) -> Vec<Row> {
let Some(registered) = registered().get(page) else {
let Some(registered) = self.pages.get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
@@ -176,7 +98,6 @@ impl OptionsUi {
added::Row::Note(text) => Row::Note(text),
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
added::Row::Button { label } => wide(tr(label)),
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
@@ -199,7 +120,7 @@ impl OptionsUi {
/// A registered page's row was pressed: a key row waits for a key, any other row hears it.
pub(super) fn page_act(&mut self, page: usize, r: usize, shift: bool) -> Vec<Event> {
let Some(registered) = registered().get(page) else {
let Some(registered) = self.pages.get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
@@ -278,7 +199,7 @@ impl OptionsUi {
) -> Vec<Event> {
let (i, k) = self.selected_native_key.take().unwrap_or_default();
if name == PAGE_KEY {
if let Some(page) = registered().get(i) {
if let Some(page) = self.pages.get(i) {
page.act(k, added::Act::Bind(value));
}
return vec![Event::Changed];
@@ -300,12 +221,11 @@ mod tests {
use super::*;
use std::sync::Mutex;
/// A page with a heading, a switch, a number that steps either way, a key and a volume.
/// A page with a heading, a switch, a number that steps either way and a key.
struct Sample {
on: Mutex<bool>,
step: Mutex<i32>,
key: Mutex<String>,
volume: Mutex<f32>,
}
impl added::Page for Sample {
@@ -332,12 +252,6 @@ mod tests {
label: "test.sample.key",
key: self.key.lock().unwrap().clone(),
});
let volume = *self.volume.lock().unwrap();
rows.push(added::Row::Slider {
label: "test.sample.volume",
value: volume,
text: added::Label::Value(format!("{}%", (volume * 100.0).round())),
});
}
fn act(&self, row: usize, act: added::Act) {
match (row, act) {
@@ -348,20 +262,15 @@ mod tests {
(2, added::Act::Press) => *self.step.lock().unwrap() += 1,
(2, added::Act::PressBack) => *self.step.lock().unwrap() -= 1,
(3, added::Act::Bind(key)) => *self.key.lock().unwrap() = key,
(4, added::Act::Slide(v)) => *self.volume.lock().unwrap() = v,
other => panic!("row and act the page doesn't expect: {other:?}"),
}
}
fn tooltip(&self, row: usize) -> Option<added::Label> {
(row == 1).then_some(added::Label::Key("test.sample.switch.tooltip"))
}
}
static SAMPLE: Sample = Sample {
on: Mutex::new(false),
step: Mutex::new(0),
key: Mutex::new(String::new()),
volume: Mutex::new(0.0),
};
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
@@ -388,8 +297,10 @@ mod tests {
#[test]
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
let mut s = Settings::default();
TEST_PAGES.set(Some(&PAGES));
let mut ui = OptionsUi::default();
let mut ui = OptionsUi {
pages: &PAGES,
..OptionsUi::default()
};
ui.show(Page::Horizon);
press(&mut ui, &mut s, Act::Push(Page::Added(0)), false);
assert_eq!(ui.page(), Page::Added(0));
@@ -412,53 +323,5 @@ mod tests {
assert_eq!(events, [Event::Changed]);
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
assert_eq!(ui.selected_key, None);
// The volume is one of the screen's own sliders: dragged to its middle, then let go.
let size = (480.0, 240.0);
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let slider = widgets
.iter()
.find(|w| {
matches!(
w.kind,
WKind::Slider {
opt: Opt::Page(0, 4),
..
}
)
})
.expect("the volume's slider");
let (x, y) = (slider.x + slider.w / 2.0, slider.y + 1.0);
ui.click(&mut s, &atlas, x, y, size, [480, 240], false, false);
let volume = *SAMPLE.volume.lock().unwrap();
assert!((0.45..0.55).contains(&volume), "{volume}");
assert!(ui.release().contains(&Event::Changed));
// The slider shows the page's value once let go.
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let shown = widgets.iter().find_map(|w| match w.kind {
WKind::Slider {
opt: Opt::Page(0, 4),
value,
..
} => Some(value),
_ => None,
});
assert_eq!(shown, Some(volume));
// The page's tooltip for its switch.
let switch = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::PageRow(0, 1),
..
}
)
})
.expect("the switch");
assert_eq!(
tooltip_text(&s, switch).map(|t| t.plain()),
Some(tr("test.sample.switch.tooltip").plain())
);
}
}
@@ -1,252 +0,0 @@
//! OptiFine custom skies (`horizon_optifine::sky`) on the engine's sky layers
//! ([`crate::custom_sky`]): a pack's layers with their decoded textures, read once with the pack,
//! and each frame's choice of layers, their brightness and rotation. A pack without
//! `optifine/sky` has none, and the sky stays vanilla.
use std::path::Path;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Now, Skies, SkyLayer, Transition};
use crate::custom_sky::{self as engine, Draw, Image, SkyLayers, State};
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
}
/// The engine's sky blend for OptiFine's.
fn blend(blend: Blend) -> engine::Blend {
match blend {
Blend::Add => engine::Blend::Add,
Blend::Subtract => engine::Blend::Subtract,
Blend::Multiply => engine::Blend::Multiply,
Blend::Dodge => engine::Blend::Dodge,
Blend::Burn => engine::Blend::Burn,
Blend::Screen => engine::Blend::Screen,
Blend::Replace => engine::Blend::Replace,
Blend::Overlay => engine::Blend::Overlay,
Blend::Alpha => engine::Blend::Alpha,
}
}
impl SkyLayers for CustomSky {
fn images(&self) -> &[Image] {
&self.images
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// The layers whose conditions hold, eased over their `transition`, at the brightness of
/// their fade times.
fn draws(
&self,
world: i32,
conditions: &engine::Conditions,
sun_turns: f32,
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
let conditions = &Conditions {
day_time: conditions.day_time,
weather: match conditions.weather {
engine::Weather::Clear => Now::Clear,
engine::Weather::Rain => Now::Rain,
engine::Weather::Thunder => Now::Thunder,
},
biome: conditions.biome,
y: conditions.y,
};
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), 0.0);
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
*eased = target;
target
} else {
let mut t = Transition { value: *eased };
*eased = t.step(target, seconds, layer.transition);
*eased
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: blend(layer.blend),
brightness,
rotation,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::custom_sky::{Conditions, Weather};
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Weather::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, engine::Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
@@ -1,26 +0,0 @@
//! The resource pack formats vanilla doesn't read (OptiFine's, Polytone's) on the engine's
//! extension points ([`crate::pack_hooks`]). This is platform code (docs/layers.md) kept in this
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
//! moves to a platform crate unchanged, and nothing else in the crate names it.
mod custom_sky;
use std::path::Path;
use std::sync::Arc;
use crate::pack_hooks::PackFormats;
/// OptiFine's and Polytone's formats.
pub struct Formats;
impl PackFormats for Formats {
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
let sky = custom_sky::CustomSky::load(root);
(!sky.is_empty()).then(|| Arc::new(sky) as _)
}
}
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
pub fn install() {
crate::pack_hooks::install(&Formats);
}
@@ -1,35 +0,0 @@
//! The engine's extension points for resource pack formats vanilla doesn't read (OptiFine's,
//! Polytone's: platform code, docs/layers.md). The engine asks [`formats`] once per pack load and
//! gets engine types back (tables, or an object it calls at most once a frame), never a format's
//! own types. The platform installs its formats at startup ([`install`]); with none installed,
//! packs load as vanilla reads them.
use std::path::Path;
use std::sync::{Arc, OnceLock};
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
/// (its folder at `root`); the default is "nothing in this format".
pub trait PackFormats: Send + Sync {
/// The sky layers the overlay draws over the vanilla sky, if it has any.
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
None
}
}
/// No format beyond vanilla's.
struct Vanilla;
impl PackFormats for Vanilla {}
static FORMATS: OnceLock<&'static dyn PackFormats> = OnceLock::new();
/// Installs the pack formats, before the first pack loads. The first call wins: later ones are
/// ignored, so each test or probe that needs the formats may install them.
pub fn install(formats: &'static dyn PackFormats) {
let _ = FORMATS.set(formats);
}
/// The installed pack formats, or vanilla's alone.
pub fn formats() -> &'static dyn PackFormats {
FORMATS.get().copied().unwrap_or(&Vanilla)
}
+110 -44
View File
@@ -31,7 +31,6 @@ mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
mod spawn;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -1223,9 +1222,8 @@ pub struct ParticleLevel<'a> {
pub collision: &'a BlockCollisionRegistry<'a>,
/// Local player feet position and vertical velocity (`PlayerCloudParticle`).
pub player: Option<([f64; 3], f64)>,
pub cached_chunk: std::cell::Cell<Option<((i32, i32), Option<&'a crate::chunk::Chunk>)>>,
}
impl<'a> ParticleLevel<'a> {
impl ParticleLevel<'_> {
fn collide(&self, bb: Aabb, d: [f64; 3]) -> [f64; 3] {
let mut scene = SceneCollisions {
world: self.world,
@@ -1233,6 +1231,9 @@ impl<'a> ParticleLevel<'a> {
subject: None,
};
let movement = Vec3::new(d[0], d[1], d[2]);
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
// block of the swept box's cells can collide, it finds no shape and the
// movement stands, as the resolution below would return it.
if scene.cells_without_collision(bb.expand_towards(movement)) {
return d;
}
@@ -1248,46 +1249,38 @@ impl<'a> ParticleLevel<'a> {
p[2].floor() as i32,
]
}
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> horizon_world::registry::BlockStateId {
let (cx, cz) = (x >> 4, z >> 4);
let chunk = match self.cached_chunk.get() {
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
_ => {
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
self.cached_chunk.set(Some(((cx, cz), c)));
c
}
};
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
}
/// The fluid at a block and `FluidState.getHeight`.
fn fluid(&self, b: [i32; 3]) -> (Fluid, f64) {
let s = self.facts.get(self.block(b[0], b[1], b[2]));
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
if s.fluid == Fluid::None {
return (Fluid::None, 0.0);
}
let above = self.facts.get(self.block(b[0], b[1] + 1, b[2]));
let h = if above.fluid == s.fluid {
let above = self.facts.get(self.world.block(b[0], b[1] + 1, b[2]));
let height = if above.fluid == s.fluid {
1.0
} else {
f64::from(s.fluid_level) / 9.0
};
(s.fluid, h)
(s.fluid, height)
}
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
let s = self.facts.get(self.block(b[0], b[1], b[2]));
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
}
/// `getCollisionShape(...).max(Y, x, z)` for cached shapes.
fn collision_top(&self, b: [i32; 3], fx: f64, fz: f64) -> f64 {
let state = self.block(b[0], b[1], b[2]);
let state = self.world.block(b[0], b[1], b[2]);
match self.collision.cached_shapes().cached_shape(state) {
Ok(Some(shape)) if !shape.is_empty() => shape.max_at(Axis::Y, fx, fz),
Ok(Some(_)) => 0.0,
_ if self.facts.get(state).full_collision => 1.0,
_ => 0.0,
_ => {
if self.facts.get(state).full_collision {
1.0
} else {
0.0
}
}
}
}
}
@@ -2027,6 +2020,73 @@ impl ParticleEngine {
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
})
}
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if count == 0 {
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
self.add(options, at, v);
return;
}
for _ in 0..count {
let r = &mut self.random;
let p = [
at[0] + r.next_gaussian() * spread[0],
at[1] + r.next_gaussian() * spread[1],
at[2] + r.next_gaussian() * spread[2],
];
let v = [
r.next_gaussian() * speed,
r.next_gaussian() * speed,
r.next_gaussian() * speed,
];
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
if let Some(requests) = &mut self.created_requests {
requests.push((options.clone(), p, v));
return;
}
let seed = self.seeds.next_long();
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
/// How many particles of each type were made so far.
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
&self.made
@@ -3992,12 +4052,15 @@ impl ParticleEngine {
}
Behaviour::Lava => {
p.base_tick(level);
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
self.add(
&ParticleOptions::simple(ParticleType::Smoke),
p.position,
p.velocity,
);
if !p.removed {
let odds = p.age as f32 / p.lifetime as f32;
if p.random.next_float() > odds {
self.add(
&ParticleOptions::simple(ParticleType::Smoke),
p.position,
p.velocity,
);
}
}
}
Behaviour::Crit => {
@@ -4081,7 +4144,9 @@ impl ParticleEngine {
*c *= f64::from(0.98f32);
}
if p.on_ground {
p.removed |= p.random.next_float() < 0.5;
if p.random.next_float() < 0.5 {
p.removed = true;
}
p.velocity[0] *= f64::from(0.7f32);
p.velocity[2] *= f64::from(0.7f32);
}
@@ -4121,10 +4186,13 @@ impl ParticleEngine {
} => {
p.base_tick(level);
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
if !(fluid == Fluid::Water && source)
|| p.position[1] >= y_end
|| p.position[1] <= y_prev
{
if !(p.removed || (fluid == Fluid::Water && source)) {
p.removed = true;
}
if !p.removed && p.position[1] >= y_end {
p.removed = true;
}
if !p.removed && p.position[1] <= y_prev {
p.removed = true;
}
// randomHorizontalWiggling, then a second, horizontal move.
@@ -4148,8 +4216,9 @@ impl ParticleEngine {
p.base_tick(level);
self.sprite_from_age(p);
if p.age as f32 > fade_start {
let frames_since_fade_out_start = p.age as f32 - fade_start;
p.rgba[3] =
(p.lifetime as f32 - (p.age as f32 - fade_start)) / p.lifetime as f32;
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
}
}
Behaviour::NoxiousGasCloud => {
@@ -4613,7 +4682,7 @@ impl ParticleEngine {
self.sprite_from_age(p);
half(p);
let b = ParticleLevel::block_at(p.position);
if level.facts.is_air(level.block(b[0], b[1], b[2])) {
if level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
p.velocity[1] -= f64::from(0.0074f32);
}
}
@@ -4621,7 +4690,7 @@ impl ParticleEngine {
Behaviour::Firefly => {
p.base_tick(level);
let b = ParticleLevel::block_at(p.position);
if !level.facts.is_air(level.block(b[0], b[1], b[2])) {
if !level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
p.removed = true;
} else {
p.rgba[3] = fade_amount(p.lifetime_progress(p.age as f32), 0.3, 0.5);
@@ -5085,12 +5154,11 @@ fn hdr_tier(ty: ParticleType) -> u8 {
}
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
if (a[0] - a[1]).abs() < 1.0e-5 {
a[0]
} else {
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
return a[0];
}
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
}
struct Starter {
@@ -5521,7 +5589,6 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.set_sprites.push(vec![10, 11, 12]);
@@ -5613,7 +5680,6 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let mut particle = Particle::blank(ParticleType::Smoke, [0.0; 3]);
@@ -124,7 +124,6 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut e = engine();
let mut p = e
@@ -490,14 +490,14 @@ enum Subject<'a> {
}
/// What one expression runs against: its subject, the frame and the level.
struct Ctx<'a, 'l> {
struct Ctx<'a> {
subject: Subject<'a>,
random: &'a mut LegacyRandom,
custom: &'a mut f64,
customs: &'a mut BTreeMap<String, f64>,
frame: &'a Frame,
registry: &'a Registry,
level: Option<&'a ParticleLevel<'l>>,
level: Option<&'a ParticleLevel<'a>>,
blocks: Vec<[i32; 3]>,
}
@@ -515,7 +515,7 @@ fn axis(name: &str) -> usize {
usize::from(name.as_bytes()[0] - b'x')
}
impl Ctx<'_, '_> {
impl Ctx<'_> {
fn position(&self) -> [f64; 3] {
match &self.subject {
Subject::Particle(p) => p.position,
@@ -683,7 +683,7 @@ impl Ctx<'_, '_> {
}
}
impl Host for Ctx<'_, '_> {
impl Host for Ctx<'_> {
fn root(&mut self, root: Root) -> V {
match root {
Root::Object => V::Object(SELF),
@@ -1662,7 +1662,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// Air has no emitters; the torch fires its first emitter only.
@@ -1702,7 +1701,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_animate(&level, [1, 64, 1]);
@@ -1724,7 +1722,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_animate(&level, [1, 64, 1]);
@@ -1770,7 +1767,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Registry::read(&root, Default::default(), blocks);
let frame = registry.frame.clone();
@@ -1824,7 +1820,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Registry::read(&root, Default::default(), blocks);
let mut engine = engine(registry);
@@ -1858,7 +1853,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -1911,7 +1905,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -1973,7 +1966,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
@@ -2022,7 +2014,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -2089,7 +2080,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
// The chunk's biome is id 0: plains, whose foliage is #336699.
@@ -2208,7 +2198,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
@@ -2287,7 +2276,6 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// The test chunk's biome is the table's first: a cold one first, then a warm one.
@@ -1,73 +0,0 @@
//! Where particles come into the engine: a server's particle packet and a single particle made
//! where the game asks for one.
use super::*;
impl ParticleEngine {
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if count == 0 {
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
self.add(options, at, v);
return;
}
for _ in 0..count {
let r = &mut self.random;
let p = [
at[0] + r.next_gaussian() * spread[0],
at[1] + r.next_gaussian() * spread[1],
at[2] + r.next_gaussian() * spread[2],
];
let v = [
r.next_gaussian() * speed,
r.next_gaussian() * speed,
r.next_gaussian() * speed,
];
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
if let Some(requests) = &mut self.created_requests {
requests.push((options.clone(), p, v));
return;
}
let seed = self.seeds.next_long();
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
}
@@ -93,7 +93,6 @@ fn particle_tick_cost() {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
+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));
}
}
+7 -2
View File
@@ -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()
@@ -864,8 +871,6 @@ pub enum Opt {
PadGameplayGuide,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
Page(usize, usize),
/// 3D skin layers: the player's outer layer as extruded slabs.
SkinLayers3d,
MainHand,
+1 -27
View File
@@ -192,12 +192,7 @@ impl SignData {
let side = |t: Option<&Tag>| -> SignText {
let mut out = SignText::default();
let Some(t) = t else { return out };
if let Some(messages) = t.get("messages") {
// `SignText.CODEC` reads a side all or nothing: a messages
// field that is not a list leaves the side empty.
let Tag::List(messages) = messages else {
return SignText::default();
};
if let Some(Tag::List(messages)) = t.get("messages") {
for (i, m) in messages.iter().take(4).enumerate() {
out.lines[i] = component_text(m);
out.click[i] = click_event(m);
@@ -312,27 +307,6 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
/// `SignText.CODEC` reads messages as a list: a string field fails the
/// whole side, taking its colour and glow with it.
#[test]
fn messages_that_are_not_a_list_load_an_empty_side() {
let tag = Tag::compound([(
"front_text",
Tag::compound([
("messages", Tag::String("not a list".into())),
("color", Tag::String("red".into())),
("has_glowing_text", Tag::Byte(1)),
]),
)]);
let mut decoded = SignData::default();
decoded.decode(&tag);
assert_eq!(
decoded.front,
SignText::default(),
"a failed message list takes the side's colour and glow with it"
);
}
#[test]
fn waxed_and_glowing_text_take_any_number() {
let tag = Tag::compound([
+2 -41
View File
@@ -1238,9 +1238,6 @@ pub struct EditBox {
/// Grey completion shown after the text (EditBox.setSuggestion).
pub suggestion: Option<String>,
pub text_color: u32,
/// `EditBox.setCentered`: the text starts in the middle of the box's
/// width, not at its left edge (`BookSignScreen`'s title).
pub centered: bool,
/// `EditBox.textColorUneditable`: the colour of a box `setEditable(false)`
/// made read-only, which still shows its value and caret.
pub text_color_uneditable: u32,
@@ -1539,7 +1536,6 @@ impl EditBox {
focused_at: Instant::now(),
suggestion: None,
text_color: rgba(224, 224, 224, 255),
centered: false,
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
@@ -1563,16 +1559,6 @@ impl EditBox {
self.y
}
}
/// `EditBox.updateTextPosition`: where the text starts, `(width - text
/// width) / 2` in for a centred box (vanilla's `textX`), else at
/// `text_x` (4 in from a border).
pub fn text_start_x(&self, text_width: f32) -> f32 {
if self.centered {
self.x + ((self.width - text_width) / 2.0).floor()
} else {
self.text_x()
}
}
pub fn reset_blink(&mut self) {
self.focused_at = Instant::now();
}
@@ -1655,13 +1641,8 @@ impl EditBox {
/// EditBox.findClickedPositionInText: the character boundary under GUI `x`.
pub fn position_at(&mut self, atlas: &HudAtlas, x: f32) -> usize {
self.layout(atlas);
let room = (x.floor() - self.text_x()).min(self.inner_width());
let rest: String = self.value.chars().skip(self.display).collect();
let shown: String = rest
.chars()
.take(atlas.font.fit(&rest, self.inner_width()))
.collect();
let start = self.text_start_x(atlas.font.width_text(&Text::literal(shown.as_str())));
let room = (x.floor() - start).min(self.inner_width());
self.display + atlas.font.fit(&rest, room)
}
/// EditBox.onClick: the cursor to the pointer, extending the selection
@@ -1868,12 +1849,7 @@ impl EditBox {
),
}
};
let start = if self.centered {
self.text_start_x(atlas.font.width_text(&Text::literal(displayed.as_str())))
} else {
self.text_x()
};
let (tx, ty) = (start, self.text_y());
let (tx, ty) = (self.text_x(), self.text_y());
let mut draw_x = tx;
if !displayed.is_empty() {
let half = if on_screen {
@@ -2674,19 +2650,4 @@ mod tests {
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
assert_eq!(edit.value, "name");
}
/// `EditBox.setCentered` (`BookSignScreen`'s title box): with the flag the
/// text starts `(width - text width) / 2` in, which is also where a click
/// maps; without it the text starts at the box's left edge.
#[test]
fn a_centred_box_starts_its_text_in_the_middle() {
let mut edit = EditBox::new(10.0, 0.0, 114.0, 9.0);
edit.bordered = false;
assert_eq!(edit.text_start_x(0.0), 10.0);
edit.centered = true;
assert_eq!(edit.text_start_x(0.0), 67.0);
assert_eq!(edit.text_start_x(18.0), 58.0);
// (114 - 5) / 2 = 54.5, rounded down.
assert_eq!(edit.text_start_x(5.0), 64.0);
}
}
+3 -4
View File
@@ -122,13 +122,12 @@ controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-duals
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
horizon-extras = ["discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
friends = ["dep:horizon-friends"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs) and joining one.
friends = ["horizon-client-lib/friends", "dep:horizon-friends"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
@@ -97,7 +97,6 @@ pub fn main() -> Result<()> {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(&root, &Default::default(), &Default::default());
let mut out = BufWriter::new(File::create(&args[2])?);
@@ -194,7 +194,6 @@ pub fn main() -> Result<()> {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let v = |key: &str| -> [f64; 3] {
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
+4 -49
View File
@@ -612,7 +612,6 @@ fn do_animate_tick(
facts: ctx.facts,
collision: ctx.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
engine.polytone_animate(&level, pos);
let facts = ctx.facts.get(state);
@@ -1995,10 +1994,8 @@ pub fn campfire_particles<'a>(
}
}
}
/// `CampfireBlock.makeParticles`: the cosy or signal smoke through
/// `addAlwaysVisibleParticle(.., true, ..)` (it overrides the limiter but still
/// rolls the Particles option); `smoking` adds the plain smoke puff at the logs
/// through `addParticle`, so the limiter can drop it.
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
/// `smoking` adds the plain smoke puff at the logs.
pub fn make_campfire_smoke(
engine: &mut ParticleEngine,
pos: [i32; 3],
@@ -2017,13 +2014,7 @@ pub fn make_campfire_smoke(
} else {
T::CampfireCosySmoke
};
engine.add_with(
&ParticleOptions::simple(ty),
[x, y, z],
[0.0, 0.07, 0.0],
true,
true,
);
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
if smoking {
let r = engine.random();
let dx = r.next_double() / 4.0 * sign(r);
@@ -2033,49 +2024,13 @@ pub fn make_campfire_smoke(
f64::from(pos[1]) + 0.4,
f64::from(pos[2]) + 0.5 + dz,
];
add(engine, T::Smoke, at, [0.0, 0.005, 0.0]);
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `CampfireBlock.makeParticles` through `ClientLevel.doAddParticle`: the
/// cosy smoke overrides the limiter, the plain smoke does not, so it is
/// dropped beyond 32 blocks and at Particles: Minimal, and at Decreased
/// each puff is dropped when `calculateParticleLevel`'s `nextInt(3)` is 0.
#[test]
fn doused_campfire_smoke_goes_through_the_particle_limiter() {
use horizon_core::random::RandomSource as _;
use std::collections::{HashMap, HashSet};
let run = |level: u8, camera: [f64; 3]| {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(5, 9, 0);
engine.created_requests = Some(Vec::new());
engine.particle_level = level;
engine.camera = camera;
for _ in 0..20 {
make_campfire_smoke(&mut engine, [0, 64, 0], false, true);
}
let made = engine.created_requests.take().unwrap();
let count = |ty| made.iter().filter(|(o, ..)| o.ty == ty).count();
(count(T::CampfireCosySmoke), count(T::Smoke))
};
let near = [0.5, 65.0, 0.5];
assert_eq!(run(0, near), (20, 20), "All");
assert_eq!(run(2, near), (20, 0), "Minimal");
assert_eq!(run(0, [100.5, 65.0, 0.5]), (20, 0), "100 blocks away");
// Decreased: per puff the cosy smoke's roll, then the plain smoke's.
let mut level = LegacyRandom::new(9);
let kept = (0..20)
.filter(|_| {
level.next_int_bound(3).unwrap();
level.next_int_bound(3).unwrap() != 0
})
.count();
assert!(0 < kept && kept < 20);
assert_eq!(run(1, near), (20, kept), "Decreased");
}
#[test]
fn classifies_animated_blocks() {
let none = |_: &str| None;
+9 -9
View File
@@ -19,13 +19,13 @@ pub use horizon_client_render::{
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
providers, pvp, pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask,
server_terrain, settings, shader_options, shader_pack_list, signs, skins, social, sound_engine,
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
streaming, structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors,
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
@@ -70,6 +70,7 @@ pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
@@ -86,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;
@@ -102,8 +104,6 @@ pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
#[cfg(feature = "horizon-extras")]
pub mod official_servers;
pub mod offline_name;
pub mod old_world;
pub mod osk;
+1 -1
View File
@@ -84,7 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
mod fluids;
mod item_fall;
mod item_no_physics;
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
pub use fluids::{fluid_flow, update_entity_fluids, water_clip};
#[cfg(test)]
mod drop_motion_tests {
@@ -45,30 +45,6 @@ pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
[flow.x, flow.y, flow.z]
}
/// `Entity.move`'s fall-reset ray along a move from `from`, up to 8 blocks:
/// `ClipContext.Fluid.WATER` (the water's shape) and
/// `ClipContext.Block.FALLDAMAGE_RESETTING` (a block `tagged` says is a reset
/// block clips as a full block). Both shapes are queried for every visited
/// block, the water's first (its first query fixes the shared cached shape);
/// either one hitting ends the ray.
pub fn fall_reset_hit(
world: &World,
fluids: &FluidResources,
tagged: impl Fn(BlockPos) -> bool,
from: Vec3,
moved: Vec3,
) -> bool {
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
let water = water_clip(world, fluids, from, to, p);
let reset = tagged(p)
&& horizon_core::geometry::VoxelShape::block()
.clip(from, to, p)
.is_some();
(water || reset).then_some(())
});
matches!(hit, Ok(Some(())))
}
/// `ClipContext.Fluid.WATER` in a fall-reset ray through `p`: the water's
/// shape from the shared `FlowingFluid.shapes` cache (the first query of a
/// fluid state fixes its height), as the player's own ray reads it.
+2 -2
View File
@@ -233,7 +233,7 @@ fn is_animal(id: &str) -> bool {
use crate::mobs::persistent_anger;
/// The single player's UUID (`offline_uuid("Player")`): the entity the
/// neutral mobs are angry at.
pub(crate) fn player_uuid() -> [i32; 4] {
fn player_uuid() -> [i32; 4] {
let u = horizon_protocol::crypto::offline_uuid("Player");
[
(u >> 96) as u32 as i32,
@@ -1297,7 +1297,7 @@ pub fn mob_to_nbt(
crate::mobs::nautilus::to_nbt(m, map);
}
"minecraft:sulfur_cube" => crate::mobs::sulfur_cube::to_nbt(m, map),
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map, &mut uuid_of),
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map),
"minecraft:sniffer" => crate::mobs::sniffer::to_nbt(m, map),
"minecraft:wither" => {
map.insert("Invul".into(), Tag::Int(m.extra.counter));
+5 -5
View File
@@ -13,8 +13,6 @@ pub fn main_with(mods: crate::mod_api::Mods) {
log::warn!("Trait mods already installed; ignoring the second list");
}
horizon_threads::configure_allocator();
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
horizon_client::pack_formats::install();
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
if !cfg!(feature = "horizon-extras") {
horizon_client::options_ui::HORIZON_EXTRAS
@@ -23,9 +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);
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
#[cfg(feature = "friends")]
game::register_friends();
// 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::*;
@@ -1,9 +1,9 @@
//! Play with Friends: Horizon's own sharing of the open world behind an
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
//! invite code (`game/friends.rs`). The pause menu's Friends button, the icon
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
//! a screen to vanilla: the menu background, a centred title and text,
//! vanilla's buttons and Done in the footer.
use horizon_client::{
use crate::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
@@ -14,7 +14,7 @@ use horizon_client::{
pub const TITLE: &str = "Play with Friends";
/// A string of this screen: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
horizon_client::lang::get_or(key, english)
crate::lang::get_or(key, english)
}
/// The title in the current language.
pub fn title() -> String {
@@ -245,8 +245,8 @@ pub fn draw(
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
crate::widgets::declare_action(
crate::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
+64 -30
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;
@@ -421,7 +421,6 @@ mod server_links;
mod server_packs;
mod server_travel;
mod shader_packs;
mod share_view;
mod sheep_dye;
mod shelf;
mod shield;
@@ -468,7 +467,6 @@ mod trial_spawner;
mod trial_visuals;
mod trident;
mod tutorial;
mod tweaks;
mod ui_control;
mod use_hand;
mod using_item;
@@ -598,8 +596,6 @@ use resource_loader::{
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
run_loading, write_report,
};
#[cfg(feature = "friends")]
pub(crate) use share_view::register_friends;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -653,7 +649,7 @@ enum Screen {
/// WorldOptionsScreen (game/lan_screen.rs).
WorldOptions,
/// Play with Friends, from the pause menu's Friends button
/// (game/friends.rs, game/friends/screen.rs).
/// (`friends_screen`, game/friends.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
@@ -2021,8 +2017,8 @@ pub struct PackResources {
clouds: Arc<(u32, u32, Vec<bool>)>,
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
forcefield: Arc<(u32, u32, Vec<u8>)>,
/// Custom sky layers (OptiFine's `optifine/sky`) with their textures; none without them.
custom_sky: Arc<dyn horizon_client::custom_sky::SkyLayers>,
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
/// The overworld's, Nether's and End's attribute stacks with the packs' Polytone biome and
/// dimension modifiers (#11486), when a pack has some: what the frame draws.
environments: Option<Arc<[Environment; 3]>>,
@@ -2225,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.
@@ -2814,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.
@@ -2882,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,
@@ -2906,6 +2901,9 @@ struct Game<'a> {
level_shapes: horizon_client::chunk::LevelShapes,
/// This game is that server thread's (game/integrated.rs).
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
#[cfg(feature = "friends")]
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
@@ -5490,6 +5488,34 @@ impl Game<'_> {
);
cancel
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
pub(super) fn visual_ticks(&self) -> u64 {
if self.clock.still() {
self.world.game_time.max(0) as u64
} else {
self.ticks
}
}
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
pub(super) fn texture_clock(&self) -> u64 {
if self.clock.still() {
self.visual_ticks()
} else {
self.texture_ticks
}
}
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
pub(super) fn visual_millis(&self) -> i64 {
if self.clock.still() {
self.visual_ticks() as i64 * 50
} else {
self.started.elapsed().as_millis() as i64
}
}
/// `PresenceHandler.getPresenceStatus`: what an all-sharing player is doing, for
/// the friends service's `updatePresence`.
fn presence_status(&self) -> horizon_client::social::PresenceStatus {
@@ -5504,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
@@ -5737,6 +5763,7 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
self.announce_sleep_status();
@@ -5846,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)
@@ -5959,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
@@ -6024,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);
}
});
@@ -6459,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()
{
@@ -9869,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,
@@ -10680,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);
}
@@ -10928,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,
)))
@@ -11438,7 +11464,6 @@ impl Game<'_> {
// the world's own ticks.
horizon_client::social::set_presence_status(self.presence_status());
horizon_client::social::tick();
self.share_tick();
// Minecraft.tick: ClientTickEvent.Pre before any of it.
self.native_client_tick_pre();
if self.title.is_none() && !self.pause {
@@ -11471,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();
@@ -11582,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;
@@ -12182,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(),
@@ -12206,6 +12231,8 @@ impl<'a> Game<'a> {
test_defs_snapshot: false,
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
#[cfg(feature = "friends")]
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
@@ -12690,9 +12717,16 @@ pub fn run_with() -> Result<()> {
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
let player_dir = horizon_client::logging::player_dir();
// horizon-extras: the official server is Horizon's (docs/forking.md).
#[cfg(feature = "horizon-extras")]
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
if cfg!(feature = "horizon-extras")
&& let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir)
{
log::warn!("could not add the official server to servers.dat: {e}");
}
if let Err(e) = horizon_client::multiplayer::withdraw_partner_servers(&player_dir) {
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
}
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
+16 -8
View File
@@ -686,15 +686,23 @@ impl Game<'_> {
if fall == 0.0 || length < 1.0 {
return fall;
}
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
let reset = &self.res.motion().fall_reset;
let hit = horizon_client::entities::fall_reset_hit(
&self.world,
&self.res.motion().fluids,
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
from,
moved,
);
if hit { 0.0 } else { fall }
let fluids = &self.res.motion().fluids;
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
// A tagged block clips as a full block, not its collider.
let name = &self.res.facts().get(self.world.block(p.x, p.y, p.z)).name;
if reset.contains(&**name) && VoxelShape::block().clip(from, to, p).is_some() {
return Some(());
}
// The water's shape, through the shared fluid shape cache.
horizon_client::entities::water_clip(&self.world, fluids, from, to, p).then_some(())
});
if matches!(hit, Ok(Some(()))) {
0.0
} else {
fall
}
}
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
/// `b` already where it landed): on the ground the fall resets; a move
+3 -78
View File
@@ -33,8 +33,6 @@ fn book_title_box() -> ui::EditBox {
edit.bordered = false;
edit.max_length = 15;
edit.text_color = BLACK;
// BookSignScreen sets the title box centred.
edit.centered = true;
edit.focused = true;
edit
}
@@ -70,23 +68,6 @@ pub(super) enum BookScreen {
lectern: Option<(i32, i32, i32)>,
},
}
impl BookScreen {
/// `BookSignScreen`'s constructor: its title box opens with the title
/// kept from the last time, which Cancel stored (`titleValue`).
fn begin_signing(&mut self) {
if let Self::Edit {
signing,
title,
title_edit,
..
} = self
{
*signing = true;
title_edit.set_value(title.as_str());
title_edit.reset_blink();
}
}
}
/// The pages a book screen reads: a written book's components (so their
/// click events stay), else a writable book's text.
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
@@ -1005,7 +986,7 @@ impl Game<'_> {
title,
..
}) if !title.trim().is_empty() => self.save_book(true),
Some(book @ BookScreen::Edit { .. }) => book.begin_signing(),
Some(BookScreen::Edit { signing, .. }) => *signing = true,
_ => return Err("not a book and quill".into()),
},
Some("done") => match self.book.as_mut() {
@@ -1207,8 +1188,8 @@ impl Game<'_> {
self.turn_page(false);
} else if inside(w / 2.0 - 100.0, 196.0, 98.0, 20.0) {
click(self);
if let Some(book) = self.book.as_mut() {
book.begin_signing();
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = true;
}
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
@@ -1637,19 +1618,6 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
}
}
#[cfg(test)]
mod title_box_tests {
use super::book_title_box;
/// `BookSignScreen` sets its 15 character title box centred and unbordered.
#[test]
fn the_sign_title_box_is_centred() {
let edit = book_title_box();
assert!(edit.centered);
assert!(!edit.bordered);
assert_eq!(edit.max_length, 15);
}
}
#[cfg(test)]
mod lectern_books_tag_tests {
use super::super::native_machines::tests::server_game;
@@ -1826,46 +1794,3 @@ mod lectern_reach_tests {
);
}
}
#[cfg(test)]
mod sign_tests {
use super::{BookScreen, book_page_box, book_title_box};
/// `BookSignScreen`'s constructor sets its title box to `titleValue`, what
/// Cancel kept; reopening the sign screen shows the title typed before,
/// not an empty box beside a stale `title`.
#[test]
fn the_sign_screen_opens_with_the_title_it_kept() {
let mut book = BookScreen::Edit {
slot: 0,
pages: vec!["page".into()],
page: 0,
signing: false,
title: "My Book".into(),
title_edit: book_title_box(),
page_edit: book_page_box(),
};
assert_eq!(book_title_value(&book), "");
book.begin_signing();
let BookScreen::Edit {
signing,
title,
title_edit,
..
} = &book
else {
panic!("the book stays open");
};
assert!(signing);
assert_eq!(title.as_str(), "My Book");
assert_eq!(title_edit.value, "My Book");
assert_eq!(title_edit.cursor, 7);
}
fn book_title_value(book: &BookScreen) -> String {
match book {
BookScreen::Edit { title_edit, .. } => title_edit.value.clone(),
BookScreen::View { .. } => String::new(),
}
}
}
+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.
+14 -16
View File
@@ -42,8 +42,8 @@ impl Reply {
}
pub(super) fn duration(millis: i64) -> Text {
use horizon_server_files::duration::{DurationUnit, duration_parts};
let (count, unit) = duration_parts(millis);
use horizon_chat_filter::DurationUnit;
let (count, unit) = horizon_chat_filter::duration_parts(millis);
let key = match (unit, count == 1) {
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
@@ -93,7 +93,7 @@ pub(super) const MUTE_USAGES: [&str; 4] = [
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
match words
.get(2)
.and_then(|w| horizon_server_files::duration::parse_duration(w))
.and_then(|w| horizon_chat_filter::parse_duration(w))
{
Some(ms) => (Some(ms), 3),
None => (None, 2),
@@ -133,7 +133,7 @@ impl Game<'_> {
count: u32,
action: Action,
) {
let now = horizon_server_files::duration::now_millis();
let now = horizon_chat_filter::now_millis();
let summary = format!(
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
place.name(),
@@ -281,7 +281,7 @@ impl Game<'_> {
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
match words {
["mutelist"] => {
let now = horizon_server_files::duration::now_millis();
let now = horizon_chat_filter::now_millis();
let mutes = host.hub.mutes();
if mutes.is_empty() {
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
@@ -319,15 +319,13 @@ impl Game<'_> {
let (time, from) = mute_time(words);
vec![self.mute(host, name, time, reason(from), by)]
}
["tempmute", name, time, ..] => {
match horizon_server_files::duration::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![Reply::translated(
"horizon.moderation.invalid_duration",
&[Text::literal(*time)],
)],
}
}
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![Reply::translated(
"horizon.moderation.invalid_duration",
&[Text::literal(*time)],
)],
},
["mute", ..] => vec![Reply::translated("horizon.moderation.usage.mute", &[])],
["tempmute", ..] => vec![Reply::translated("horizon.moderation.usage.tempmute", &[])],
["unmute", ..] => vec![Reply::translated("horizon.moderation.usage.unmute", &[])],
@@ -355,10 +353,10 @@ impl Game<'_> {
let Some((uuid, name)) = user else {
return Reply::no_player();
};
let until = time.map(|ms| horizon_server_files::duration::now_millis() + ms);
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
let what = time.map_or_else(
|| "until unmuted".into(),
|ms| format!("for {}", horizon_server_files::duration::duration_text(ms)),
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
);
let why = reason
.as_deref()
@@ -227,16 +227,16 @@ impl Game<'_> {
);
}
for m in self.mobs.iter() {
// A mob with no effects swirls nothing: skipped before its size
// is worked out, which every mob in a crowded world would pay
// for (#14053).
if !m.alive() || (m.extra.effect_particles.is_empty() && m.extra.effects.is_empty()) {
if !m.alive() {
continue;
}
let size = (f64::from(m.width()), f64::from(m.height()));
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
if m.extra.effect_particles.is_empty() {
// A mob of this game: its own effects, visible and not ambient.
if m.extra.effects.is_empty() {
continue;
}
let list: Vec<_> = m
.extra
.effects
@@ -92,7 +92,6 @@ fn a_mods_fireworks_burst_and_bang_on_the_client_only() {
facts: game.res.facts(),
collision: game.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
game.particles.tick(&level);
};
+5 -5
View File
@@ -1567,11 +1567,11 @@ impl Game<'_> {
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
"dirty_light": self.world.dirty_light.len(),
"renderer": renderer,
"display": self.window.as_deref().and_then(horizon_client::window::display).map(|d| json!({
"width": d.width,
"height": d.height,
"scale": d.scale,
"refresh_hz": d.refresh_hz,
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
"width": m.size().width,
"height": m.size().height,
"scale": m.scale_factor(),
"refresh_hz": m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
})),
"uptime_seconds": self.started.elapsed().as_secs_f64(),
"server": self.remote_status(),
+783 -2
View File
@@ -2,10 +2,8 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod mob_geometry;
mod projectile_world;
mod pufferfish_player;
mod spawn_eggs;
use super::{
Dim, Game, Hand, InteractTarget, entity_culling::EntityCullDecision,
@@ -7452,6 +7450,171 @@ impl Game<'_> {
}
Ok(true)
}
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
// it south without a level-random draw.
let Some(id) =
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
stack,
self.acting_player_is_op(),
);
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
}
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
// SpawnEggItem.spawnMob: `EntityType.spawn`.
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
return Ok(false);
};
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
// `PostSpawnProcessor` puts the egg's components on it.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
{
s.count -= 1;
if s.count == 0 {
*self.held_mut() = None;
}
}
Ok(true)
}
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
/// the mob it spawned, once the guards let it through, as
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
/// it, loaded back into the same mob (`/data merge entity`'s loader).
pub(super) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
return;
};
let kind = self.mobs[i].kind.id.clone();
let op = self.acting_player_is_op();
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
else {
return;
};
let own = self.mob_tree_nbt(i);
let tag = horizon_client::data_cmd::merged(&own, data);
// Nothing to report: the mob's own compound always loads back.
let _ = self.apply_entity_nbt(super::commands::Target::Mob(id), tag);
}
/// A beam target's centre (half its height), interpolated.
fn beam_target(&self, who: Option<Who>, alpha: f64) -> Option<glam::DVec3> {
match who? {
@@ -7622,6 +7785,624 @@ impl Game<'_> {
}
}
}
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
let skinning = self.skinning();
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
paused: self.pause,
first_person: self.camera_type.first_person(),
third_person_reversed: self.camera_type
== super::perspective::CameraType::ThirdPersonFront,
..Default::default()
});
for mob in &self.mobs {
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let is_player = &*mob.kind.id == "minecraft:player";
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& beam.is_none()
{
let (lo, hi) = super::entity_culling::cull_box(mob, alpha);
let (lo, hi) = (
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
);
if !view.intersects(lo, hi) {
continue;
}
}
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& beam.is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
// probe (its eye) is in, at the position this frame draws rather
// than this tick's.
let probe = mob.light_probe(alpha);
let light = self.world.light(probe[0], probe[1], probe[2]);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
// A player's own hook (`native_player_submit`) is asked for the
// other players in view.
let who = is_player
.then(|| self.remote_player_name(mob.id))
.flatten()
.map(|name| NativePlayerRender { name, local: false });
let modded = if layers_only {
false
} else if let Some(who) = who {
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
} else {
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
};
if modded {
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
self.sulfur_cube_body_geometry(
mob,
alpha as f32,
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
packed,
out,
);
}
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
let armor = if is_player {
self.worn_armor_layers(mob)
} else {
Vec::new()
};
let body = out.len();
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
// then only the outline draws.
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
let posable = skinning
&& !is_player
&& !layers_only
&& silhouette_on_gpu
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
_ => None,
} {
for v in &mut out[body..] {
v.layer |= u32::from(tier) << 16;
}
}
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
}
if let Some((_, name)) = self
.remote_player_names()
.into_iter()
.find(|(id, _)| *id == mob.id)
{
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& super::cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
None => (mark, Some((lo, hi))),
});
}
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws, their silhouette too.
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
// What the GPU draws of the mob counts by its bounds' corners.
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = beam
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
{
let a = alpha;
let eye = glam::DVec3::new(
mob.previous.x + (mob.position.x - mob.previous.x) * a,
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
mob.previous.z + (mob.position.z - mob.previous.z) * a,
) - camera;
horizon_client::mobs::guardian::beam_geometry(
eye.as_vec3(),
(target - camera).as_vec3(),
time,
scale,
uv,
out,
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
let live = self.creatures.projectiles.iter().map(|a| {
let at = [
a.previous.x + (a.position.x - a.previous.x) * alpha,
a.previous.y + (a.position.y - a.previous.y) * alpha,
a.previous.z + (a.position.z - a.previous.z) * alpha,
];
(a, at)
});
// ItemPickupParticle: a picked-up arrow flying to its collector.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
{
glowing = Some((out.len(), rgb));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
// EntityRenderer.getPackedLightCoords: the block its light probe
// (the eye) is in, at the position this frame draws rather than
// this tick's, and not the one under its feet.
let light = self.world.light(
at[0].floor() as i32,
a.light_probe_y(alpha).floor() as i32,
at[2].floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
// on fire are drawn at 15 block light (the fireball renderers'
// own override); the sky light stays the level's.
if let Some(block) = a.block_light_override() {
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
}
if a.is_arrow() {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
// and the same for the pitch, so a turning arrow is drawn
// between the two ticks it turned over.
let alpha32 = alpha as f32;
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
out,
);
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
) {
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
let model = &self.entity_models.shulker_bullet;
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core
// (`RenderTypes.entityTranslucent`).
let shell = out.len();
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
horizon_client::gfx::mark_oit(&mut out[shell..]);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::wither::skull_kind(a) {
let model = &self.entity_models.wither_skull;
let tex = if dangerous {
"entity/wither/wither_invulnerable"
} else {
"entity/wither/wither"
};
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("head", a.yaw.to_radians());
poses.xrot("head", a.pitch.to_radians());
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
horizon_client::mobs::render::draw(
model,
base,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
}
continue;
}
if let Some(stack) = a.drawn_item() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
self.submit_item_world(&state, pose, packed, out);
continue;
}
// `Entity.displayFireAnimation`: a thrown item that flew through a
// fire block burns (`ThrowableProjectile.tick`'s
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
// over its box as any other entity renderer does.
if a.on_fire() {
let size = a.kind.size() as f32;
flame_geometry(atlas, c, size, size, self.yaw, light, out);
}
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
// returns (a fire charge until `setItem` gave it another), its model
// at the renderer's scale for the kind. `packed` is already 15, the
// renderer's full bright.
if let Some(stack) = a.item.as_ref() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation())
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
self.submit_item_world(&state, pose, packed, out);
continue;
}
let Some(name) = a.item_sprite() else {
continue;
};
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
/// `ThrownItemRenderer.submit` turns the item by `CameraRenderState.orientation`
/// (`Camera.setRotation`): the billboard's right and up axes are the camera's.
@@ -1,629 +0,0 @@
//! Drawing the mobs, players and projectiles the client sees, each frame (`mob_geometry`).
use super::*;
impl Game<'_> {
pub(in crate::game) fn mob_geometry(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
let skinning = self.skinning();
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
paused: self.pause,
first_person: self.camera_type.first_person(),
third_person_reversed: self.camera_type
== super::super::perspective::CameraType::ThirdPersonFront,
..Default::default()
});
for mob in &self.mobs {
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let is_player = &*mob.kind.id == "minecraft:player";
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& beam.is_none()
{
let (lo, hi) = super::super::entity_culling::cull_box(mob, alpha);
let (lo, hi) = (
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
);
if !view.intersects(lo, hi) {
continue;
}
}
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& beam.is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
// probe (its eye) is in, at the position this frame draws rather
// than this tick's.
let probe = mob.light_probe(alpha);
let light = self.world.light(probe[0], probe[1], probe[2]);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
// A player's own hook (`native_player_submit`) is asked for the
// other players in view.
let who = is_player
.then(|| self.remote_player_name(mob.id))
.flatten()
.map(|name| NativePlayerRender { name, local: false });
let modded = if layers_only {
false
} else if let Some(who) = who {
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
} else {
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
};
if modded {
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
self.sulfur_cube_body_geometry(
mob,
alpha as f32,
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
packed,
out,
);
}
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
let armor = if is_player {
self.worn_armor_layers(mob)
} else {
Vec::new()
};
let body = out.len();
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
// then only the outline draws.
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
let posable = skinning
&& !is_player
&& !layers_only
&& silhouette_on_gpu
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
_ => None,
} {
for v in &mut out[body..] {
v.layer |= u32::from(tier) << 16;
}
}
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
}
if let Some((_, name)) = self
.remote_player_names()
.into_iter()
.find(|(id, _)| *id == mob.id)
{
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& super::super::cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
None => (mark, Some((lo, hi))),
});
}
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws, their silhouette too.
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
// What the GPU draws of the mob counts by its bounds' corners.
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = beam
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
{
let a = alpha;
let eye = glam::DVec3::new(
mob.previous.x + (mob.position.x - mob.previous.x) * a,
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
mob.previous.z + (mob.position.z - mob.previous.z) * a,
) - camera;
horizon_client::mobs::guardian::beam_geometry(
eye.as_vec3(),
(target - camera).as_vec3(),
time,
scale,
uv,
out,
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
let live = self.creatures.projectiles.iter().map(|a| {
let at = [
a.previous.x + (a.position.x - a.previous.x) * alpha,
a.previous.y + (a.position.y - a.previous.y) * alpha,
a.previous.z + (a.position.z - a.previous.z) * alpha,
];
(a, at)
});
// ItemPickupParticle: a picked-up arrow flying to its collector.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::super::pickups::Taken::Arrow(a) => {
Some((&**a, super::super::pickups::position(p, alpha)))
}
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
{
glowing = Some((out.len(), rgb));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
// EntityRenderer.getPackedLightCoords: the block its light probe
// (the eye) is in, at the position this frame draws rather than
// this tick's, and not the one under its feet.
let light = self.world.light(
at[0].floor() as i32,
a.light_probe_y(alpha).floor() as i32,
at[2].floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
// on fire are drawn at 15 block light (the fireball renderers'
// own override); the sky light stays the level's.
if let Some(block) = a.block_light_override() {
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
}
if a.is_arrow() {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
// and the same for the pitch, so a turning arrow is drawn
// between the two ticks it turned over.
let alpha32 = alpha as f32;
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
out,
);
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
) {
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
let model = &self.entity_models.shulker_bullet;
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core
// (`RenderTypes.entityTranslucent`).
let shell = out.len();
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
horizon_client::gfx::mark_oit(&mut out[shell..]);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::super::wither::skull_kind(a) {
let model = &self.entity_models.wither_skull;
let tex = if dangerous {
"entity/wither/wither_invulnerable"
} else {
"entity/wither/wither"
};
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("head", a.yaw.to_radians());
poses.xrot("head", a.pitch.to_radians());
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
horizon_client::mobs::render::draw(
model,
base,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
}
continue;
}
if let Some(stack) = a.drawn_item() {
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
self.submit_item_world(&state, pose, packed, out);
continue;
}
// `Entity.displayFireAnimation`: a thrown item that flew through a
// fire block burns (`ThrowableProjectile.tick`'s
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
// over its box as any other entity renderer does.
if a.on_fire() {
let size = a.kind.size() as f32;
flame_geometry(atlas, c, size, size, self.yaw, light, out);
}
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
// returns (a fire charge until `setItem` gave it another), its model
// at the renderer's scale for the kind. `packed` is already 15, the
// renderer's full bright.
if let Some(stack) = a.item.as_ref() {
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation())
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
self.submit_item_world(&state, pose, packed, out);
continue;
}
let Some(name) = a.item_sprite() else {
continue;
};
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
@@ -1,171 +0,0 @@
//! Spawn eggs: a baby from an egg used on its own kind, an egg used on a block or a fluid, and the
//! egg's entity data on the mob it spawned.
use super::*;
impl Game<'_> {
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
pub(super) fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
// it south without a level-random draw.
let Some(id) =
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(in crate::game) fn spawn_egg_fluid_hit(&self) -> Option<super::super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(in crate::game) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
pub(super) fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let fluid = matches!(self.guest_use, Some(super::super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
stack,
self.acting_player_is_op(),
);
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
}
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
// SpawnEggItem.spawnMob: `EntityType.spawn`.
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
return Ok(false);
};
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
// `PostSpawnProcessor` puts the egg's components on it.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
{
s.count -= 1;
if s.count == 0 {
*self.held_mut() = None;
}
}
Ok(true)
}
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
/// the mob it spawned, once the guards let it through, as
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
/// it, loaded back into the same mob (`/data merge entity`'s loader).
pub(in crate::game) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
return;
};
let kind = self.mobs[i].kind.id.clone();
let op = self.acting_player_is_op();
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
else {
return;
};
let own = self.mob_tree_nbt(i);
let tag = horizon_client::data_cmd::merged(&own, data);
// Nothing to report: the mob's own compound always loads back.
let _ = self.apply_entity_nbt(super::super::commands::Target::Mob(id), tag);
}
}
+4 -35
View File
@@ -471,44 +471,13 @@ impl Game<'_> {
continue;
}
self.cushions[i].check_interval = 0;
if !self.check_cushion(i) {
i += 1;
}
}
}
/// `Cushion.tickAtCheckInterval` plus the survive check; true when the cushion broke.
fn check_cushion(&mut self, i: usize) -> bool {
let c = self.cushions[i].clone();
let p = c.position;
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
// collidedWithFluid: the fluid box of the cushion's own block against its box.
if let Some((height, _)) =
self.fluid_height_of(x, y, z, Some(horizon_client::blocks::Fluid::Lava))
{
let fluid = Aabb::new(
Vec3::new(f64::from(x), f64::from(y), f64::from(z)),
Vec3::new(
f64::from(x) + 1.0,
f64::from(y) + f64::from(height),
f64::from(z) + 1.0,
),
);
if aabb(c.bounds())
.deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7))
.intersects(fluid)
{
// LAVA_IGNITE's lavaHurt deals 4 damage, which breaks the cushion.
let c = self.cushions[i].clone();
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
self.lava_hurt_sound([p.x, p.y, p.z]);
return true;
continue;
}
i += 1;
}
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
return true;
}
false
}
/// `LivingEntity.stopRiding` off a cushion: `dismountVehicle` beside it,
+7 -7
View File
@@ -1,8 +1,8 @@
//! Custom skies (`horizon_client::custom_sky`, OptiFine's format): each frame, the layers of the
//! world's sky whose day, weather, biome and height hold at the camera, handed to the renderer,
//! which draws them over the vanilla sky. A pack without layers hands it none.
//! OptiFine custom skies (`horizon_client::custom_sky`): each frame, the layers of the world's sky
//! whose day, weather, biome and height hold at the camera, handed to the renderer, which draws
//! them over the vanilla sky. A pack without `optifine/sky` hands it none.
use super::*;
use horizon_client::custom_sky::{Conditions, Weather};
use horizon_optifine::sky::{Conditions, Now};
/// The layers' eased brightness between frames, and when the last frame was.
#[derive(Default)]
@@ -46,11 +46,11 @@ impl Game<'_> {
let mut state = std::mem::take(&mut self.custom_sky.state);
// Level.isThundering / isRaining.
let weather = if thunder > 0.9 && rain > 0.2 {
Weather::Thunder
Now::Thunder
} else if rain > 0.2 {
Weather::Rain
Now::Rain
} else {
Weather::Clear
Now::Clear
};
let conditions = Conditions {
day_time: self.view_day_time(),
+2 -2
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
@@ -2671,7 +2671,7 @@ mod tests {
#[test]
fn a_temporarily_banned_player_is_refused_with_the_end_until_it_has_passed() {
use super::{BanEntry, BanList, Lists, NameAndId};
use horizon_server_files::duration::now_millis;
use horizon_chat_filter::now_millis;
let dir = std::env::temp_dir().join(format!("horizon-can-login-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut lists = Lists {
+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
+214 -378
View File
@@ -1,35 +1,24 @@
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
//! screen (`friends_screen`, the pause menu's Friends button) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
//! configured adds a short code and a fallback. The integrated server is the
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
//!
//! It sees the game only through `horizon_hooks::share` (the engine's `ShareView`) and
//! `Hooks::lan_closed`, and keeps what it shares in its own static,
//! so it can leave the engine for horizon-friends (spec #14167). The screen and the
//! `horizon-ctl friends` command below still reach it from `Game` until then.
use super::Game;
use horizon_client::{Result, hud::HudBatch};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_hooks::{
Hooks,
share::{LanOptions, ShareView},
//! loopback address.
use super::{Game, lan::LanSettings};
use horizon_client::{
Result,
friends_screen::{self, Action, FriendsView},
hud::HudBatch,
lang,
text::Text,
};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
mod screen;
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
struct Sharing {
pub(super) struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
opened_lan: bool,
@@ -42,52 +31,12 @@ struct Sharing {
paths: (u32, u32),
}
/// The world shared, if one is. The game has one window player who can share (the engine
/// calls the hooks for it only), so one is all there is.
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
static ON: AtomicBool = AtomicBool::new(false);
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
fn take() -> Option<Sharing> {
let taken = sharing().take();
ON.store(false, Ordering::Relaxed);
taken
}
/// Registered by the engine's entry while this module is part of it.
pub struct Friends;
pub static HOOKS: Friends = Friends;
/// No `Hooks::share` yet: the code and sessions are read while the screen is drawn or
/// `horizon-ctl friends status` asks, as before. The screen's page uses it (#14167 step 2a).
impl Hooks for Friends {
/// The LAN server is closing: the code goes with it.
fn lan_closed(&self) {
if ON.load(Ordering::Relaxed)
&& let Some(mut sharing) = take()
{
sharing.host.stop();
}
}
}
pub const HELP: &[&str] = &[
"friends host share the open world behind an invite code",
"friends stop stop sharing",
"friends status the code, how friends reach this host, the guests",
];
/// A string in the player's language, or the English one.
fn tr(world: &dyn ShareView, key: &str, english: &str) -> String {
world.tr(key).unwrap_or_else(|| english.to_owned())
}
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
fn stun_servers() -> Vec<String> {
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
@@ -104,99 +53,101 @@ fn env_flag(name: &str, default: bool) -> bool {
std::env::var(name).map_or(default, |v| v != "0")
}
/// Start sharing the open world.
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
if ON.load(Ordering::Relaxed) {
return Ok(());
}
if !world.can_share() {
return Err("open a singleplayer world first".into());
}
let (port, opened_lan) = match world.lan_port() {
Some(port) => (port, false),
// ShareToLanScreen's defaults, as the world options screen offers them.
None => (world.open_lan(LanOptions::default())?, true),
};
let host = Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
});
let host = match host {
Ok(h) => h,
Err(e) => {
if opened_lan {
world.close_lan();
}
return Err(e.to_string());
impl Game<'_> {
/// Start sharing the open world.
pub(super) fn friends_start(&mut self) -> Result<()> {
if self.friends.is_some() {
return Ok(());
}
};
*sharing() = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
ON.store(true, Ordering::Relaxed);
Ok(())
}
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: false,
force_game_mode: true,
})?;
}
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
let host = match Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
}) {
Ok(h) => h,
Err(e) => {
if opened_lan {
self.lan_close(true);
}
return Err(e.into());
}
};
self.friends = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
Ok(())
}
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
fn stop(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(mut sharing) = take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
world.close_lan();
} else {
let text = tr(
world,
"horizon.friends.stopped",
"Stopped sharing the invite code.",
);
world.chat(&text);
}
}
/// New codes and sessions, while the screen is drawn and when `friends status` asks.
fn tick(world: &mut dyn ShareView) {
if !ON.load(Ordering::Relaxed) {
return;
}
let mut announce = false;
let mut stopped = None;
{
let mut guard = sharing();
let Some(sharing) = guard.as_mut() else {
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
pub(super) fn friends_stop(&mut self) {
let Some(mut sharing) = self.friends.take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
self.lan_close(true);
} else {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
)));
}
}
/// The LAN server is closing: the code goes with it.
pub(super) fn friends_lan_closed(&mut self) {
if let Some(mut sharing) = self.friends.take() {
sharing.host.stop();
}
}
/// New codes and sessions, once a tick and while the pause menu is drawn.
pub(super) fn tick_friends(&mut self) {
let Some(sharing) = self.friends.as_mut() else {
return;
};
let mut announce = false;
let mut stopped = None;
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
sharing.notice = Some(tr(
world,
sharing.notice = Some(lang::get_or(
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
@@ -207,8 +158,7 @@ fn tick(world: &mut dyn ShareView) {
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some(tr(
world,
sharing.notice = Some(lang::get_or(
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
@@ -217,8 +167,7 @@ fn tick(world: &mut dyn ShareView) {
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = tr(
world,
let text = lang::get_or(
"horizon.friends.relay_stopped",
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
);
@@ -227,272 +176,159 @@ fn tick(world: &mut dyn ShareView) {
}
}
}
if let Some(text) = stopped {
self.system_message(Text::literal(text));
}
if announce {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
)));
}
}
if let Some(text) = stopped {
world.chat(&text);
}
if announce {
let text = tr(
world,
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
);
world.chat(&text);
}
}
fn copy(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
return;
};
world.copy(&code);
if let Some(s) = sharing().as_mut() {
s.copied = Some(Instant::now());
}
}
/// What Play with Friends shows.
fn status(world: &dyn ShareView) -> FriendsView {
// Another server, or a splitscreen guest in P1's world: nothing to share.
if !world.can_share() {
return FriendsView::default();
}
let guard = sharing();
let Some(s) = guard.as_ref() else {
return FriendsView {
shown: true,
..FriendsView::default()
pub(super) fn friends_copy(&mut self) {
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
return;
};
};
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
let state = s.host.state();
let mut status = if s.code.is_none() {
tr(
world,
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&code);
if let Some(s) = self.friends.as_mut() {
s.copied = Some(Instant::now());
}
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
}
/// `horizon-ctl friends host|stop|status`.
fn control(
word: Option<&str>,
world: &mut dyn ShareView,
) -> std::result::Result<serde_json::Value, String> {
use serde_json::json;
match word {
Some("host") => {
start(world)?;
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
}
Some("stop") => {
stop(world);
Ok(json!({ "ok": true }))
}
Some("status") => {
tick(world);
let view = status(world);
let guard = sharing();
let sharing = guard.as_ref().filter(|_| view.sharing);
let state = sharing.map(|s| s.host.state());
Ok(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": sharing.map_or(0, |s| s.host.sessions()),
"direct": sharing.map_or(0, |s| s.paths.0),
"relayed": sharing.map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": world.lan_port(),
}))
}
_ => Err("friends host|stop|status".into()),
}
}
impl Game<'_> {
/// Play with Friends. The world is paused under it in singleplayer, so
/// new codes and sessions are read while it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
let world = &mut self.share_view();
tick(world);
let view = status(world);
self.tick_friends();
let view = self.friends_view();
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
});
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
}
pub(super) fn friends_screen_click(&mut self) {
let view = status(&self.share_view());
let view = self.friends_view();
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
if action == Action::Done {
self.screen = super::Screen::Paused;
return;
}
let world = &mut self.share_view();
match action {
Action::Share => {
if let Err(e) = start(world) {
let text = tr(
world,
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e, 1);
world.chat(&text);
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(
lang::get_or(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e.to_string(), 1),
));
}
}
Action::Copy => copy(world),
Action::Stop => stop(world),
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the folder buttons open their folders.
Action::Help => {
if let Some(url) = &view.help {
world.open_url(url);
super::packs::open_uri(url);
}
}
Action::Done => {}
Action::Done => self.screen = super::Screen::Paused,
}
}
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.can_share()
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
};
let state = s.host.state();
let mut status = if s.code.is_none() {
lang::get_or(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
use serde_json::json;
if words[0] != "friends" {
return Ok(None);
}
Ok(Some(control(
words.get(1).copied(),
&mut self.share_view(),
)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A world as the engine shows it, recording what the crate did to it.
#[derive(Default)]
struct Fake {
can_share: bool,
lan: Option<u16>,
refuse_lan: bool,
did: Vec<String>,
}
impl ShareView for Fake {
fn can_share(&self) -> bool {
self.can_share
}
fn lan_port(&self) -> Option<u16> {
self.lan
}
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
self.did.push(format!("open_lan {options:?}"));
if self.refuse_lan {
return Err("no free port".into());
match words.get(1).copied() {
Some("host") => {
self.friends_start()?;
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
}
self.lan = Some(25565);
Ok(25565)
Some("stop") => {
self.friends_stop();
Ok(Some(json!({ "ok": true })))
}
Some("status") => {
self.tick_friends();
let view = self.friends_view();
let state = self.friends.as_ref().map(|s| s.host.state());
Ok(Some(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": self.lan_port(),
})))
}
_ => Err("friends host|stop|status".into()),
}
fn close_lan(&mut self) {
self.did.push("close_lan".into());
self.lan = None;
}
fn players(&self, each: &mut dyn FnMut(&str)) {
each("Alex");
}
fn chat(&mut self, text: &str) {
self.did.push(format!("chat {text}"));
}
fn copy(&mut self, text: &str) {
self.did.push(format!("copy {text}"));
}
fn open_url(&mut self, url: &str) {
self.did.push(format!("open_url {url}"));
}
fn tr(&self, _key: &str) -> Option<String> {
None
}
}
#[test]
fn a_world_that_cannot_be_shared_is_left_alone() {
// Another server, or a splitscreen guest in P1's world.
let mut guest = Fake {
lan: Some(25565),
..Fake::default()
};
assert_eq!(
control(Some("host"), &mut guest).unwrap_err(),
"open a singleplayer world first"
);
stop(&mut guest);
copy(&mut guest);
assert!(guest.did.is_empty(), "{:?}", guest.did);
assert!(!status(&guest).shown);
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
assert_eq!(start(&mut world).unwrap_err(), "no free port");
assert_eq!(
world.did,
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
);
// Offered, not shared: the guests show once sharing does.
let view = status(&world);
assert!(view.shown && !view.sharing && view.guests.is_empty());
assert!(!ON.load(Ordering::Relaxed));
}
}
+8 -10
View File
@@ -1,18 +1,16 @@
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
//! no world is shared, the pause menu has no Friends button (`FriendsView::shown` is false, as on
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
use super::{Game, Screen};
use horizon_client::{Result, hud::HudBatch};
use horizon_client::{Result, friends_screen::FriendsView, hud::HudBatch};
pub const HELP: &[&str] = &[];
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
pub const TITLE: &str = "Play with Friends";
pub fn title() -> String {
horizon_client::lang::get_or("horizon.friends.title", TITLE)
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
pub(super) fn tick_friends(&mut self) {}
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
self.screen = Screen::Paused;
@@ -20,8 +18,8 @@ impl Game<'_> {
pub(super) fn friends_screen_click(&mut self) {}
pub(super) fn friends_shown(&self) -> bool {
false
pub(super) fn friends_view(&self) -> FriendsView {
FriendsView::default()
}
/// `horizon-ctl friends ...`.
+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)));
}
}
+2 -2
View File
@@ -1489,7 +1489,7 @@ impl Game<'_> {
None => self.unpublish(),
};
if closed {
self.lan_closed_hooks();
self.friends_lan_closed();
}
if closed && announce && self.integrated.is_some() {
self.system_message(Text::translate(
@@ -1499,7 +1499,7 @@ impl Game<'_> {
}
return;
}
self.lan_closed_hooks();
self.friends_lan_closed();
self.native_players_leaving();
let Some(mut host) = self.lan.take() else {
return;
@@ -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));
@@ -152,14 +152,6 @@ impl WorldOptions {
}
self.port.value.parse::<u16>().ok().filter(|p| *p >= 1024)
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: the port box explains itself while
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
/// shows there too; a remote world has no such box.
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
.then(|| Text::translate("lanServer.port", &[]))
}
fn snapshot(&self) -> Snapshot {
Snapshot {
default_mode: self.default_mode,
@@ -1011,10 +1003,6 @@ impl Game<'_> {
hover,
);
}
// The widgets are drawn; the port box's tooltip goes over them.
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
ui::tooltip(batch, atlas, &[tip], cursor);
}
}
/// The mouse wheel over the screen (ScrollableLayout, 10 pixels a step).
@@ -1911,15 +1899,4 @@ mod tests {
assert!(o.locked);
assert!(!o.active(Button::Difficulty));
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
/// label; a remote world (`o.remote`) has no port box to hover.
#[test]
fn the_port_box_explains_itself_on_hover() {
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
}
}
@@ -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).
@@ -91,7 +91,7 @@ pub(super) fn build_renderer(
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
renderer.set_sky_shaders(atlas.sky_shader.as_deref(), atlas.stars_shader.as_deref());
renderer.set_custom_sky(&*pack.custom_sky);
renderer.set_custom_sky(&pack.custom_sky);
renderer.set_shared_material_maps(&atlas.material_maps);
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
@@ -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),
}
}
}
+10 -6
View File
@@ -157,7 +157,7 @@ impl Game<'_> {
/// The numbers `Mob.compareArmor` and `compareWeapons` read off `stack`
/// in `slot` for the mob `m`.
pub(super) fn stack_facts(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> Facts {
let (armor, toughness, attack) = self.attributes(m, stack, slot);
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
Facts {
armor,
armor_toughness: toughness,
@@ -173,18 +173,22 @@ impl Game<'_> {
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
/// attribute starts from the mob's own value. (armour, toughness, damage)
fn attributes(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> (f64, f64, f64) {
let modifiers = self.items().attribute_modifiers(stack);
fn attributes(&self, m: &Mob, id: &str, slot: Slot) -> (f64, f64, f64) {
let empty: &[(String, String, f64, String, String)] = &[];
let modifiers = self
.items()
.get(id)
.map_or(empty, |i| i.modifiers.as_slice());
(
equip::attribute_with(&modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
equip::attribute_with(modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
equip::attribute_with(
&modifiers,
modifiers,
"minecraft:armor_toughness",
f64::from(m.kind.armor_toughness),
slot,
),
equip::attribute_with(
&modifiers,
modifiers,
"minecraft:attack_damage",
f64::from(m.attrs.attack_damage),
slot,
@@ -25,7 +25,6 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
self.particles.tick(&level);
}
@@ -10,7 +10,11 @@ use horizon_client::{
net::FRIEND,
};
use horizon_core::geometry::Vec3;
#[cfg(feature = "friends")]
use horizon_friends::invite::{self, Parsed};
use std::borrow::Cow;
#[cfg(feature = "friends")]
use std::net::SocketAddr;
use winit::keyboard::KeyCode;
/// HUD and first-person hand visibility. Other hand-render restrictions still apply.
@@ -105,11 +109,28 @@ impl NativeWorldId {
}
}
/// The part of a friend's invite anyone may see (`horizon_hooks::tunnel::Resolver::public_part`).
/// A host's code is fresh each time it shares, so a friend's world is known for that sharing only.
/// Without Play with Friends no invite is read: a `friend:` world shows no part of it.
/// The part of a friend's invite anyone may see. A host's code is fresh each
/// time it shares, so a friend's world is known for that sharing only. The
/// relay is kept beside the addresses: two hosts on other networks can share
/// a private address and the fixed direct port.
#[cfg(feature = "friends")]
fn invite_public_part(code: &str) -> String {
horizon_hooks::engine::tunnel().map_or_else(String::new, |r| r.public_part(code))
match invite::parse(code) {
Some(Parsed::Short(code)) => code.nameplate,
Some(Parsed::Long(invite)) => {
let relay = invite
.relay
.map(|r| format!("{}/{}", r.address, r.code.nameplate));
let endpoints = invite.endpoints.iter().map(SocketAddr::to_string);
endpoints.chain(relay).collect::<Vec<_>>().join(",")
}
None => String::new(),
}
}
/// Without Play with Friends no invite is read: a `friend:` world shows no part of it.
#[cfg(not(feature = "friends"))]
fn invite_public_part(_code: &str) -> String {
String::new()
}
/// A living entity known to the client, before interpolation. No authoritative writes.
@@ -537,6 +558,8 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "friends")]
use horizon_friends::code::Code;
#[test]
fn a_world_is_its_save_in_single_player_and_its_address_on_a_server() {
@@ -550,6 +573,52 @@ mod tests {
);
}
#[test]
#[cfg(feature = "friends")]
fn friends_worlds_are_told_apart_by_their_hosts_never_by_their_secrets() {
const PORT: u16 = 50000;
const RELAY: &str = "relay.example.com:443";
// A short code: its nameplate, then its secret.
let short = |nameplate: &str, secret: &str| Code {
nameplate: nameplate.into(),
secret: secret.into(),
};
let invite = |secret: u8, host: &str, relay: Option<Code>| {
let code = invite::Invite {
secret: [secret; invite::SECRET],
port: PORT,
endpoints: vec![format!("{host}:{PORT}").parse().unwrap()],
relay: relay.map(|code| invite::RelayRef {
address: RELAY.into(),
code,
}),
}
.encode();
NativeWorldId::of(None, &format!("{FRIEND}{code}"))
};
assert_ne!(
invite(1, "192.168.1.5", None),
invite(1, "192.168.1.6", None),
"two friends"
);
assert_ne!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(1, "192.168.1.5", Some(short("EFGH", "WXYZ"))),
"two friends on other networks behind the same private address"
);
assert_eq!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(2, "192.168.1.5", Some(short("ABCD", "QRST"))),
"the invite's and the relay code's secrets take no part"
);
let typed = |code: Code| NativeWorldId::of(None, &format!("{FRIEND}{code}"));
assert_eq!(typed(short("ABCD", "WXYZ")), typed(short("ABCD", "QRST")));
assert_eq!(
typed(short("ABCD", "WXYZ")),
NativeWorldId::Server(format!("{FRIEND}ABCD"))
);
}
#[test]
fn a_hud_tells_chat_and_death_from_other_screens() {
assert_eq!(
+1 -9
View File
@@ -102,16 +102,8 @@ impl Game<'_> {
if food && m.health < m.attrs.max_health {
// feed(player, hand, stack, 2, 1).
let sound = nautilus::eat_sound(m);
// TamableAnimal.feed: heals 2 x the food's nutrition, 1 for an
// item with no food component.
let nutrition = self
.res
.items()
.get(item)
.and_then(|i| i.food.as_ref())
.map(|f| f.0 as i32);
let m = &mut self.mobs[index];
m.heal(nautilus::feed_heal(nutrition));
m.heal(2.0);
self.use_nautilus_food(item);
self.nautilus_sound(index, sound);
return true;
+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()
+23 -102
View File
@@ -226,25 +226,6 @@ impl Game<'_> {
});
}
/// `Entity.collide`'s `getEntityCollisions` for a body a piston or a
/// shulker lid pushes: the entities it can collide with
/// (`canCollideWith`, which for every mover but a vehicle is
/// `canBeCollidedWith`): a boat, a living shulker, a happy ghast held
/// still. A pushed boat or minecart collides by `canVehicleCollide`
/// instead, which this doesn't cover, and the player moves itself.
fn pushed_entity_colliders(&self, target: Target) -> Vec<Aabb> {
match target {
Target::Entity(EntityHandle::Boat(_) | EntityHandle::Cart(_)) => Vec::new(),
Target::Entity(handle) => {
let own = with_entity!(self, handle, entity => Entity::bounds(entity));
let mut boxes = self.item_entity_collisions();
boxes.retain(|b| *b != own);
boxes
}
_ => Vec::new(),
}
}
/// The collision view of the moving piston at `pos` takes the progress
/// its tick gave it. One no longer moving keeps its entry until
/// `sync_moving_pistons`.
@@ -303,7 +284,6 @@ impl Game<'_> {
self.shulker_push_player(movement);
return;
}
let entities = self.pushed_entity_colliders(target);
let Ok(moved) = pushed_movement(
SceneCollisions {
world: &self.world,
@@ -318,7 +298,6 @@ impl Game<'_> {
body.step_height,
body.on_ground,
None,
&entities,
) else {
return;
};
@@ -686,7 +665,6 @@ impl Game<'_> {
self.projectile_piston_block_effects(i, at);
}
self.piston_block_triggers(before, before);
self.release_after_block_move(target);
return;
}
// `Entity.move`: a projectile a cobweb or powder snow holds stops dead
@@ -717,7 +695,6 @@ impl Game<'_> {
}
_ => None,
};
let entities = self.pushed_entity_colliders(target);
let resolved = with_piston_noclip(piston_direction, || {
pushed_movement(
SceneCollisions {
@@ -732,7 +709,6 @@ impl Game<'_> {
step_height,
on_ground,
tnt.as_ref().map(|t| t.3),
&entities,
)
.unwrap_or(Vec3::ZERO)
});
@@ -771,9 +747,6 @@ impl Game<'_> {
|| limited.length_squared() - resolved.length_squared() < 1.0e-7;
if moved {
self.apply_push(target, resolved);
} else {
// A move the collisions cut to nothing still ends in the check.
self.release_after_block_move(target);
}
if let Some((i, from, ..)) = tnt {
self.finish_tnt_piston_move(i, from, resolved);
@@ -891,37 +864,26 @@ impl Game<'_> {
}
Target::Entity(handle) => {
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement));
self.release_after_block_move(target);
let Some(bounds) =
with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
}
}
}
/// `AbstractArrow.move` with a mover other than the arrow itself (a
/// piston, a shulker lid): after `Entity.move`, whether or not it went
/// anywhere, a stuck arrow or trident with nothing to collide with within
/// 0.06 starts falling (`shouldFall`, `startFalling`). Its own tick only
/// checks once the block at its position changes, a tick later.
fn release_after_block_move(&mut self, target: Target) {
let Target::Entity(handle) = target else {
return;
};
let Some(bounds) = with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
}
/// `causeBounce`: a slime block sets the velocity along the movement axis.
fn bounce(&mut self, target: Target, movement: Direction) {
match target {
@@ -977,16 +939,19 @@ fn pushed_movement<W>(
step_height: f32,
on_ground: bool,
context: Option<CollisionContext<'_>>,
entities: &[Aabb],
) -> horizon_client::Result<Vec3>
where
W: BlockCollisionWorld<Error = std::io::Error> + EntityMovementWorld<Error = std::io::Error>,
{
// Without the entity's own context, the one `resolve` reads at its feet.
let context = context.unwrap_or_else(|| CollisionContext::position(before.min_position().y));
let mut collisions = EntityCollisions::with_border(blocks, border, at);
collisions.entities = entities;
collisions.resolve_with_context(before, movement, step_height, on_ground, context)
EntityCollisions::with_border(blocks, border, at).resolve_with_context(
before,
movement,
step_height,
on_ground,
context,
)
}
#[cfg(test)]
@@ -1015,7 +980,6 @@ mod tests {
0.0,
false,
None,
&[],
)
.expect("the push resolves");
// The wall stops the body 0.4 blocks short of the whole push.
@@ -1039,7 +1003,6 @@ mod tests {
0.0,
false,
None,
&[],
)
.expect("the push resolves");
assert!(
@@ -1558,48 +1521,6 @@ mod h255_mob_piston_ground_tests {
&mut std::collections::HashMap::new(),
);
}
/// `AbstractArrow.move` after a push the collisions cut to nothing: a stuck
/// arrow with nothing within 0.06 of it starts falling all the same.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stuck_arrow_whose_push_a_wall_stops_falls_when_nothing_holds_it() {
with_game(|game| {
assert!(game.run_command("setblock 6 202 4 minecraft:stone"));
game.creatures.projectiles.clear();
assert!(game.run_command("summon minecraft:arrow 5.75 202.5 4.5 {NoGravity:0b}"));
let i = game
.creatures
.projectiles
.iter()
.position(|p| p.arrow.is_some())
.expect("the arrow");
{
let p = &mut game.creatures.projectiles[i];
p.velocity = Vec3::ZERO;
p.stuck = true;
p.arrow.as_mut().unwrap().in_ground = true;
}
let p = &game.creatures.projectiles[i];
let (bounds, at) = (p.bounds(), [p.position.x, p.position.z]);
let before = p.position;
game.move_pushed(
Target::Entity(EntityHandle::Projectile(i)),
bounds,
at,
0.0,
false,
Vec3::new(0.3, 0.0, 0.0),
Direction::East,
&mut std::collections::HashMap::new(),
);
let p = &game.creatures.projectiles[i];
assert_eq!(p.position, before, "the stone stops the push");
assert!(
!p.arrow.as_ref().unwrap().in_ground,
"nothing holds it, so it falls"
);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_no_request_and_exhausted_budget_keep_state() {
+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))
{
@@ -143,7 +143,6 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
find_noxious_gas_source_block(&level, [pos.0, pos.1, pos.2])
}
@@ -199,7 +198,6 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
// The gas reads blocks only, so the mobs it reaches can be found
// before any gets its effect.
@@ -266,7 +264,6 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
can_be_reached_by_noxious_gas(&level, source, eye)
};
@@ -359,7 +356,6 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let height = unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks);
let area = launch_area(pos, height);
@@ -423,7 +419,6 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks)
};
+301 -22
View File
@@ -60,9 +60,6 @@ use std::{
};
use winit::{event::MouseButton, keyboard::KeyCode};
#[path = "remote_poll.rs"]
mod poll;
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
@@ -982,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
@@ -1030,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,
@@ -1525,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();
@@ -1569,7 +1566,7 @@ impl Game<'_> {
self.left = false;
self.right = false;
// The shared world is closing: its invite code ends with it.
self.lan_closed_hooks();
self.friends_lan_closed();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -2177,6 +2174,288 @@ impl Game<'_> {
pub(super) fn remote_backlog(&self) -> usize {
self.remote.as_ref().map_or(0, |r| r.backlog.len())
}
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
let r = self.remote.as_mut().unwrap();
r.loaded_sent = true;
r.level_load.finish();
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(super) fn poll_remote(&mut self) -> Result<()> {
let polled = self.poll_remote_events();
// The block events and block changes of a piston clock start and end
// thousands of moves at once: the collision view and the renderer
// take them all in one sync, before the tick or frame reads them.
if std::mem::take(&mut self.moving_pistons_stale) {
self.sync_moving_pistons();
}
polled
}
fn poll_remote_events(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
self.native_client_packet(packet);
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
self.native_client_packet(&packet);
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.last_server.joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Features(features) => {
if let Some(remote) = &mut self.remote {
remote.features = features;
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::ModNet => {
if let Some(remote) = &mut self.remote {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none, and announces its
// own features again (a Horizon server's quiet commands).
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
remote.quiet_commands = false;
remote.mod_net = false;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
// clearClientLevel drops the client level now, so its
// unload comes before the configuration screen, not
// after the next login's load.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = Some(ticks);
r.chat_tree_received = false;
}
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard,
// no waypoints (its own ClientWaypointManager: a proxy's next
// server never untracks the last one's), and its new
// ClientSuggestionProvider with no custom chat completions.
// clearClientLevel clears the narrator.
self.clear_narrator();
self.hud_disconnected();
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.attachments.clear();
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("Horizon: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
key,
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let plan = self.last_server.ended(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
horizon_client::last_server::offer(self.title.as_mut(), plan);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
/// ClientSuggestionProvider.customSuggestion: the chat's completion
/// request for an `ask_server` node goes to the server.
fn send_suggestion_request(&mut self) {
@@ -3594,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,
@@ -3616,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)
@@ -6467,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();
@@ -6804,24 +7083,24 @@ impl Game<'_> {
let mut push = Vec3::ZERO;
let order = |id: u32| r.ticked_before_player.contains(&id) == before_player;
for m in self.mobs.iter().filter(|m| order(m.id)) {
// Bats never push, nor passengers of one vehicle, nor a spectator; the
// box test comes before the lookups that far mobs never need (#14053).
let listed = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid));
let spectator = listed.is_some_and(|t| t.game_mode == SPECTATOR);
let mob_team = match listed {
Some(t) => board.players_team(&t.name),
None => teams.of(Who::Mob(m.id)),
};
// Bats never push; a spectator has no physics; passengers of the
// same vehicle never push each other.
if !m.alive()
|| spectator
|| m.species() == horizon_client::mobs::Species::Bat
|| self
.riding
.is_some_and(|v| v == m.id || m.extra.mount == Some(v))
|| !m.bounds().intersects(bounds)
{
continue;
}
let listed = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid));
let mob_team =
listed.map_or_else(|| teams.of(Who::Mob(m.id)), |t| board.players_team(&t.name));
if listed.is_some_and(|t| t.game_mode == SPECTATOR)
|| !horizon_client::scoreboard::pushable_by(mob_team, player_team, true)
{
continue;
@@ -1,288 +0,0 @@
//! The server connection's received events, once a frame (`poll_remote`): chunk packets
//! within their per-frame budget, then every other packet in order (`handle_packet`).
use super::*;
impl Game<'_> {
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(in crate::game) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
let r = self.remote.as_mut().unwrap();
r.loaded_sent = true;
r.level_load.finish();
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(in crate::game) fn poll_remote(&mut self) -> Result<()> {
let polled = self.poll_remote_events();
// The block events and block changes of a piston clock start and end
// thousands of moves at once: the collision view and the renderer
// take them all in one sync, before the tick or frame reads them.
if std::mem::take(&mut self.moving_pistons_stale) {
self.sync_moving_pistons();
}
polled
}
fn poll_remote_events(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
self.native_client_packet(packet);
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
self.native_client_packet(&packet);
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Features(features) => {
if let Some(remote) = &mut self.remote {
remote.features = features;
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::ModNet => {
if let Some(remote) = &mut self.remote {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none, and announces its
// own features again (a Horizon server's quiet commands).
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
remote.quiet_commands = false;
remote.mod_net = false;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
// clearClientLevel drops the client level now, so its
// unload comes before the configuration screen, not
// after the next login's load.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = Some(ticks);
r.chat_tree_received = false;
}
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard,
// no waypoints (its own ClientWaypointManager: a proxy's next
// server never untracks the last one's), and its new
// ClientSuggestionProvider with no custom chat completions.
// clearClientLevel clears the narrator.
self.clear_narrator();
self.hud_disconnected();
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.attachments.clear();
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("Horizon: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
key,
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
}
+3 -35
View File
@@ -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,
})
}
@@ -679,7 +679,6 @@ impl Game<'_> {
.player
.as_ref()
.map(|p| ([p.position.x, p.position.y, p.position.z], p.velocity.y)),
cached_chunk: std::cell::Cell::new(None),
};
laps.lap("tp_level");
// Resource packs' Polytone entity emitters on the player (#11486): fog, dust and
@@ -726,37 +725,6 @@ impl Game<'_> {
}
}
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
pub(super) fn visual_ticks(&self) -> u64 {
if self.clock.still() {
self.world.game_time.max(0) as u64
} else {
self.ticks
}
}
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
pub(super) fn texture_clock(&self) -> u64 {
if self.clock.still() {
self.visual_ticks()
} else {
self.texture_ticks
}
}
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
pub(super) fn visual_millis(&self) -> i64 {
if self.clock.still() {
self.visual_ticks() as i64 * 50
} else {
self.started.elapsed().as_millis() as i64
}
}
}
impl Game<'_> {
/// What the packs' Polytone animation modes read this tick (wiki "Variant Textures"): the
@@ -190,7 +190,7 @@ impl PackResources {
let clouds = named_scope(scope, "pack.clouds", || load_clouds(root));
let forcefield = named_scope(scope, "pack.forcefield", || load_forcefield(root));
let custom_sky = named_scope(scope, "pack.custom_sky", || {
horizon_client::custom_sky::load(root)
horizon_client::custom_sky::CustomSky::load(root)
});
let environments =
named_scope(scope, "pack.environments", || pack_environments(root));
@@ -246,7 +246,7 @@ impl PackResources {
names: Arc::new(names),
clouds: Arc::new(clouds),
forcefield: Arc::new(forcefield),
custom_sky,
custom_sky: Arc::new(custom_sky),
environments,
polytone_particles,
polytone_post,
+12 -7
View File
@@ -307,7 +307,7 @@ impl Game<'_> {
self.remote.is_some() && self.integrated.is_none()
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
let friends = self.friends_shown();
let friends = self.friends_view().shown;
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -375,7 +375,11 @@ impl Game<'_> {
// The icon has no label: name it for controller focus and the
// control socket's widget tree.
if i == FRIENDS_ICON {
horizon_client::widgets::record(super::friends::TITLE, [x, y, 20.0, 20.0], active);
horizon_client::widgets::record(
horizon_client::friends_screen::TITLE,
[x, y, 20.0, 20.0],
active,
);
}
ui::button(
batch,
@@ -412,7 +416,7 @@ impl Game<'_> {
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
if hover {
tooltip = Some(if i == FRIENDS_ICON {
Text::literal(super::friends::title())
Text::literal(horizon_client::friends_screen::title())
} else {
Text::translate(ICON_LABELS[i], &[])
});
@@ -505,8 +509,9 @@ impl Game<'_> {
.map(|i| 12 + i as u8)
})
.or_else(|| {
(self.friends_shown() && ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
(self.friends_view().shown
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
});
if let Some(id) = hit {
self.pause_focus = Some(id);
@@ -534,7 +539,7 @@ impl Game<'_> {
self.options.split_guest || self.split_hud.menu,
);
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
let friends = self.friends_shown();
let friends = self.friends_view().shown;
active_ids.extend(pause_icon_focus_ids(friends));
if active_ids.is_empty() {
return Ok(());
@@ -586,7 +591,7 @@ impl Game<'_> {
.filter(|b| b.5)
.map(|b| (b.4, b.0, b.1, b.2))
.collect();
let friends = self.friends_shown();
let friends = self.friends_view().shown;
for (i, (x, y, _)) in pause_icons(
w,
h,
@@ -1,115 +0,0 @@
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
use super::{Game, lan::LanSettings};
use horizon_client::text::Text;
use horizon_hooks::share::{LanOptions, ShareView};
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
/// plays P1's world and can't share it; its leaving doesn't close it).
fn shares(game: &Game<'_>) -> bool {
!game.serving() && !game.options.split_guest
}
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
/// horizon-friends (spec #14167). Before the hooks are sealed.
#[cfg(feature = "friends")]
pub(crate) fn register_friends() {
if horizon_hooks::register(&super::friends::HOOKS).is_err() {
log::warn!("Play with Friends registered after the game started: sharing is off");
}
}
impl Game<'_> {
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
pub(super) fn share_tick(&mut self) {
if shares(self) {
horizon_hooks::engine::share(&mut World(self));
}
}
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
pub(super) fn lan_closed_hooks(&self) {
if shares(self) {
horizon_hooks::engine::lan_closed();
}
}
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
/// to LAN publishes it; another server can't be.
pub(super) fn can_share(&self) -> bool {
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
}
}
impl<'g> Game<'g> {
/// The view of this window's world, for the engine's own Friends screen and command until
/// they move to the crate.
#[cfg(feature = "friends")]
pub(super) fn share_view(&mut self) -> World<'_, 'g> {
World(self)
}
}
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
impl ShareView for World<'_, '_> {
fn can_share(&self) -> bool {
self.0.can_share()
}
fn lan_port(&self) -> Option<u16> {
self.0.lan_port()
}
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
self.0
.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: options.guest_commands,
force_game_mode: options.force_game_mode,
})
.map_err(|e| e.to_string())?;
self.0
.lan_port()
.ok_or_else(|| "the world isn't open to LAN".to_owned())
}
fn close_lan(&mut self) {
self.0.lan_close(true);
}
fn players(&self, each: &mut dyn FnMut(&str)) {
let g = &self.0;
if let Some(host) = &g.lan {
for guest in &host.hub.guests {
each(&guest.name);
}
} else if g.integrated.is_some() {
// On the server thread: the other players the server lists.
let me = g.options.player_name();
for name in g.remote_online_names().unwrap_or_default() {
if name != me {
each(&name);
}
}
}
}
fn chat(&mut self, text: &str) {
self.0.system_message(Text::literal(text));
}
fn copy(&mut self, text: &str) {
use horizon_client::clipboard::Clipboard;
self.0.clipboard.set(text);
}
fn open_url(&mut self, url: &str) {
// The system browser, as the folder buttons open their folders.
super::packs::open_uri(url);
}
fn tr(&self, key: &str) -> Option<String> {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
}
}
+1 -6
View File
@@ -24,8 +24,6 @@ pub(super) struct Social {
names: HashMap<u128, String>,
page: Page,
pub(super) search: EditBox,
/// `PlayerEntry.TOOLTIP_DELAY`: the row buttons' tooltips wait 500 ms.
tooltip: social_screen::TooltipDelay,
pub(super) scroll: f32,
/// Opened from the pause menu: Done goes back there.
from_pause: bool,
@@ -40,7 +38,6 @@ impl Default for Social {
names: HashMap::new(),
page: Page::All,
search,
tooltip: Default::default(),
scroll: 0.0,
from_pause: false,
}
@@ -80,7 +77,6 @@ impl Game<'_> {
self.hide_social_tutorial();
self.social.page = Page::All;
self.social.search.set_value("");
self.social.tooltip = Default::default();
self.social.scroll = 0.0;
self.social.from_pause = from_pause;
self.screen = Screen::Social;
@@ -154,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(),
@@ -172,7 +168,6 @@ impl Game<'_> {
self.cursor,
&view,
&mut self.social.search,
&mut self.social.tooltip,
true,
);
}
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more