mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'origin/main' into fix-3153
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
9afb898e80
160 files changed
+7358
-4146
No files matched your search
@@ -1,3 +1,5 @@
|
||||
<!-- Opened by an AI agent? First line: "> 🤖 Opened by <your GitHub login>'s AI agent (<tool, model>), not by <your GitHub login> personally." Use your own login, not someone else's. -->
|
||||
|
||||
## What and why
|
||||
|
||||
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
# With the game's code mods (on in every release build, off by default).
|
||||
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
@@ -71,11 +71,11 @@ jobs:
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-mods/pack
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --lib --features horizon-mods/pack mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc --features horizon-mods/pack
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
|
||||
@@ -138,8 +138,7 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
@@ -267,9 +266,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
|
||||
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
|
||||
@@ -13,13 +13,15 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
|
||||
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
|
||||
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
|
||||
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
|
||||
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
|
||||
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
|
||||
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
|
||||
the tool: `> 🤖 Opened by <login>'s AI agent (<tool, model>), not by <login> personally.`, where `<login>`
|
||||
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
|
||||
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
|
||||
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Ludovic's agents use
|
||||
`Co-Authored-By: Claude <model> <[email protected]>` and
|
||||
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
`Co-Authored-By: Claude <model> <[email protected]>`, last line
|
||||
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
|
||||
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
|
||||
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
|
||||
@@ -74,7 +76,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
|
||||
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
|
||||
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
|
||||
python3 tools/parity.py mob_ai sculk # named harnesses
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
|
||||
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
|
||||
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
|
||||
```
|
||||
|
||||
@@ -167,17 +169,18 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
|
||||
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
|
||||
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
|
||||
`[server-thread]` issues list the gaps left.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
|
||||
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
|
||||
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
|
||||
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
|
||||
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
|
||||
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
|
||||
until they are ported.
|
||||
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
|
||||
when you touch player-facing gameplay:
|
||||
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
|
||||
thread when you touch player-facing gameplay:
|
||||
|
||||
```sh
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
|
||||
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
|
||||
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
|
||||
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
|
||||
python3 tools/parity.py dedicated_server server_entities server_player_damage
|
||||
```
|
||||
|
||||
|
||||
Generated
+3
@@ -1527,6 +1527,9 @@ name = "horizon-hdr"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ash",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/ru_ru.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
|
||||
}
|
||||
"assets/horizon/lang/uk_ua.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
@@ -266,6 +269,11 @@ mod tests {
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
let uk = Language::load(&[&empty], "uk_ua");
|
||||
assert_eq!(
|
||||
uk.get("horizon.options.on_screen_keyboard"),
|
||||
Some("Екранна клавіатура")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -91,6 +91,7 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
|
||||
/// Namespaced effect ids for mob effect storage.
|
||||
pub fn namespaced(id: &'static str) -> &'static str {
|
||||
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
|
||||
let key = id.strip_prefix("minecraft:").unwrap_or(id);
|
||||
IDS.get_or_init(|| {
|
||||
EFFECTS
|
||||
.iter()
|
||||
@@ -102,7 +103,7 @@ pub fn namespaced(id: &'static str) -> &'static str {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(id)
|
||||
.get(key)
|
||||
.copied()
|
||||
.unwrap_or(id)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,19 @@ pub fn edit_shortcut(
|
||||
})
|
||||
}
|
||||
|
||||
/// Windows reports AltGr as Ctrl plus Alt: the system presses a left Control
|
||||
/// in front of the right Alt.
|
||||
pub const ALTGR_IS_CTRL_ALT: bool = cfg!(windows);
|
||||
|
||||
/// Whether a key's text reaches the screen (`KeyboardHandler.textInput`, then
|
||||
/// `charTyped` per code point). The edit modifier's shortcuts type nothing,
|
||||
/// but `textInput` takes no modifiers: with AltGr held (Ctrl plus Alt where
|
||||
/// `altgr_is_ctrl_alt`) the layout's third-level characters are text, as `/`
|
||||
/// on ABNT2, `@`, `{` and `[` on German or the Polish letters.
|
||||
pub fn types_text(edit_modifier: bool, alt: bool, altgr_is_ctrl_alt: bool) -> bool {
|
||||
!edit_modifier || (altgr_is_ctrl_alt && alt)
|
||||
}
|
||||
|
||||
/// `StringUtil.isAllowedChatCharacter`.
|
||||
pub fn is_allowed_chat_character(c: char) -> bool {
|
||||
c != '§' && c >= ' ' && c != '\u{7f}'
|
||||
@@ -303,6 +316,22 @@ pub fn append_limited(value: &mut String, text: &str, max_chars: usize) -> bool
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn altgr_types_text_where_it_is_ctrl_alt() {
|
||||
// Plain keys and Shift type.
|
||||
assert!(types_text(false, false, true));
|
||||
assert!(types_text(false, false, false));
|
||||
// Ctrl (Cmd) shortcuts do not.
|
||||
assert!(!types_text(true, false, true));
|
||||
assert!(!types_text(true, false, false));
|
||||
// AltGr on Windows is Ctrl+Alt: ABNT2 `/`, German `@` type.
|
||||
assert!(types_text(true, true, true));
|
||||
// Where AltGr is its own modifier, Ctrl+Alt is a shortcut.
|
||||
assert!(!types_text(true, true, false));
|
||||
// Alt alone (macOS Option, Linux AltGr) types.
|
||||
assert!(types_text(false, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_need_the_edit_modifier_alone() {
|
||||
let c = Some('c');
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Two offscreen frames presented in one window, without game resources or player data.
|
||||
//! `cargo run --profile fast -p horizon-client-render --example compositor -- --smoke`
|
||||
use horizon_client_render::gfx::compositor::Compositor;
|
||||
use std::sync::Arc;
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
event::WindowEvent,
|
||||
event_loop::{ActiveEventLoop, EventLoop},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
struct Frames {
|
||||
window: Arc<Window>,
|
||||
compositor: Compositor,
|
||||
textures: [wgpu::Texture; 2],
|
||||
}
|
||||
|
||||
impl Frames {
|
||||
fn new(event_loop: &ActiveEventLoop) -> horizon_client_render::Result<Self> {
|
||||
let window = Arc::new(
|
||||
event_loop.create_window(
|
||||
Window::default_attributes()
|
||||
.with_title("Compositor: red and blue views")
|
||||
.with_inner_size(winit::dpi::PhysicalSize::new(640, 360)),
|
||||
)?,
|
||||
);
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
|
||||
Box::new(window.clone()),
|
||||
));
|
||||
let surface = instance.create_surface(window.clone())?;
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
compatible_surface: Some(&surface),
|
||||
..Default::default()
|
||||
}))?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
|
||||
let textures = [[0, 0, 255, 255], [255, 0, 0, 255]].map(|pixel| {
|
||||
let size = wgpu::Extent3d {
|
||||
width: 320,
|
||||
height: 360,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Offscreen view"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&pixel.repeat(320 * 360),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(320 * 4),
|
||||
rows_per_image: Some(360),
|
||||
},
|
||||
size,
|
||||
);
|
||||
texture
|
||||
});
|
||||
let compositor = Compositor::new(
|
||||
surface,
|
||||
&adapter,
|
||||
&device,
|
||||
&queue,
|
||||
window.inner_size().into(),
|
||||
)?;
|
||||
Ok(Self {
|
||||
window,
|
||||
compositor,
|
||||
textures,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Demo {
|
||||
frames: Option<Frames>,
|
||||
error: Option<String>,
|
||||
presented: usize,
|
||||
smoke: bool,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Demo {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.frames.is_none() {
|
||||
match Frames::new(event_loop) {
|
||||
Ok(frames) => self.frames = Some(frames),
|
||||
Err(error) => {
|
||||
self.error = Some(error.to_string());
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
|
||||
let Some(f) = &mut self.frames else { return };
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => f.compositor.resize(size.width, size.height),
|
||||
// Uncovered pixels are black; resize smaller to exercise clipping.
|
||||
WindowEvent::RedrawRequested
|
||||
if f.compositor
|
||||
.present(&[(&f.textures[0], [0, 0]), (&f.textures[1], [320, 0])]) =>
|
||||
{
|
||||
self.presented += 1;
|
||||
if self.smoke && self.presented == 10 {
|
||||
f.compositor.set_vsync(false);
|
||||
f.compositor.resize(0, 0);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(u32::MAX, u32::MAX);
|
||||
assert!(!f.compositor.present(&[]));
|
||||
f.compositor.resize(400, 240);
|
||||
let _ = f
|
||||
.window
|
||||
.request_inner_size(winit::dpi::PhysicalSize::new(400, 240));
|
||||
}
|
||||
if self.smoke && self.presented == 20 {
|
||||
println!("compositor: 20 frames, resize, suspension and clipping passed");
|
||||
event_loop.exit();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, _: &ActiveEventLoop) {
|
||||
if let Some(f) = &self.frames {
|
||||
f.window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> horizon_client_render::Result<()> {
|
||||
let mut demo = Demo {
|
||||
smoke: std::env::args().any(|a| a == "--smoke"),
|
||||
..Default::default()
|
||||
};
|
||||
EventLoop::new()?.run_app(&mut demo)?;
|
||||
if let Some(error) = demo.error {
|
||||
return Err(error.into());
|
||||
}
|
||||
if demo.smoke && demo.presented < 20 {
|
||||
return Err("smoke test closed early".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
|
||||
type Stream = std::convert::Infallible;
|
||||
pub struct Audio {
|
||||
_stream: Option<Stream>,
|
||||
device_name: String,
|
||||
pub engine: SoundEngine,
|
||||
backend: MixerBackend,
|
||||
/// Record `Heard` sounds (the subtitle overlay is listening).
|
||||
@@ -543,15 +544,48 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
)?;
|
||||
Ok(stream)
|
||||
}
|
||||
/// Available audio output device names.
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
if output_disabled(|name| std::env::var_os(name)).is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
let mut names = Vec::new();
|
||||
if let Ok(devices) = host.output_devices() {
|
||||
for device in devices {
|
||||
if let Ok(name) = device.name()
|
||||
&& !names.contains(&name)
|
||||
{
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn output_devices() -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
impl Audio {
|
||||
pub fn new(root: &Path) -> Self {
|
||||
Self::with_output(root, true)
|
||||
Self::with_device(root, "")
|
||||
}
|
||||
pub fn with_device(root: &Path, device_name: &str) -> Self {
|
||||
Self::with_output(root, true, device_name)
|
||||
}
|
||||
/// No output device: the dedicated server.
|
||||
pub fn silent(root: &Path) -> Self {
|
||||
Self::with_output(root, false)
|
||||
Self::with_output(root, false, "")
|
||||
}
|
||||
fn with_output(root: &Path, output: bool) -> Self {
|
||||
/// The output device asked for (`soundDevice`), empty for the system's default; the
|
||||
/// default plays when that one can't be opened, as in vanilla.
|
||||
pub fn device(&self) -> &str {
|
||||
&self.device_name
|
||||
}
|
||||
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
|
||||
let registry = SoundRegistry::load(root);
|
||||
if output && registry.is_empty() {
|
||||
// The game then plays nothing on a working output (#1035, #1076).
|
||||
@@ -564,7 +598,7 @@ impl Audio {
|
||||
}
|
||||
let mixer = Arc::new(Mutex::new(Mixer::default()));
|
||||
let opened = if output {
|
||||
Self::open(mixer.clone())
|
||||
Self::open_named(mixer.clone(), target_device)
|
||||
} else {
|
||||
Err("no output on a dedicated server".into())
|
||||
};
|
||||
@@ -581,6 +615,7 @@ impl Audio {
|
||||
engine.loaded = live;
|
||||
Self {
|
||||
_stream: stream,
|
||||
device_name: target_device.to_owned(),
|
||||
engine,
|
||||
backend: MixerBackend {
|
||||
mixer,
|
||||
@@ -602,10 +637,61 @@ impl Audio {
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<Stream> {
|
||||
Err("this build has no sound output (built without the `audio` feature)".into())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
}
|
||||
let host = cpal::default_host();
|
||||
if !target.is_empty()
|
||||
&& let Ok(devices) = host.output_devices()
|
||||
{
|
||||
for device in devices {
|
||||
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
|
||||
if name == target {
|
||||
match open_device(&device, mixer.clone()) {
|
||||
Ok((stream, config)) => {
|
||||
log::info!(
|
||||
"Horizon: audio output {name} ({} Hz, {} channels, {})",
|
||||
config.sample_rate().0,
|
||||
config.channels(),
|
||||
config.sample_format()
|
||||
);
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Horizon: could not open audio device '{target}': {e}; falling back to default"
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::open(mixer)
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
|
||||
self.device_name = name.to_owned();
|
||||
return Ok(());
|
||||
}
|
||||
self._stream = Some(Self::open_named(self.backend.mixer.clone(), name)?);
|
||||
self.device_name = name.to_owned();
|
||||
self.backend.live = true;
|
||||
self.engine.loaded = true;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(feature = "audio"))]
|
||||
pub fn set_device(&mut self, name: &str) -> Result<()> {
|
||||
self.device_name = name.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(feature = "audio")]
|
||||
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
|
||||
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
|
||||
return Err(reason.into());
|
||||
|
||||
@@ -653,6 +653,42 @@ pub struct Commands<'a> {
|
||||
pub registries: &'a Registries,
|
||||
}
|
||||
|
||||
/// `CommandSuggestions`' constructor arguments: how a screen's suggestions
|
||||
/// parse and where they are drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SuggestLayout {
|
||||
/// `commandsOnly`: the input is a command without a leading `/`.
|
||||
pub commands_only: bool,
|
||||
/// `onlyShowIfCursorPastError`: completions from the command's start,
|
||||
/// not only past the `/`.
|
||||
pub only_past_error: bool,
|
||||
/// `suggestionLineLimit`.
|
||||
pub line_limit: usize,
|
||||
/// `anchorToBottom`: the list and usage over a box at the bottom, else
|
||||
/// under one at the top (y 72).
|
||||
pub anchor_to_bottom: bool,
|
||||
/// `fillColor`: behind the list and the usage lines.
|
||||
pub fill: i32,
|
||||
}
|
||||
impl SuggestLayout {
|
||||
/// ChatScreen.init's `CommandSuggestions`.
|
||||
pub const CHAT: Self = Self {
|
||||
commands_only: false,
|
||||
only_past_error: false,
|
||||
line_limit: 10,
|
||||
anchor_to_bottom: true,
|
||||
fill: FILL,
|
||||
};
|
||||
/// AbstractCommandBlockEditScreen.init's `CommandSuggestions`.
|
||||
pub const COMMAND_BLOCK: Self = Self {
|
||||
commands_only: true,
|
||||
only_past_error: true,
|
||||
line_limit: 7,
|
||||
anchor_to_bottom: false,
|
||||
fill: i32::MIN,
|
||||
};
|
||||
}
|
||||
|
||||
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
|
||||
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
|
||||
/// the suggestion list, Tab completion, history).
|
||||
@@ -680,6 +716,7 @@ pub struct ChatScreen {
|
||||
pub auto_suggestions: bool,
|
||||
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
|
||||
pub abilities: ChatAbilities,
|
||||
pub layout: SuggestLayout,
|
||||
}
|
||||
/// Local completions waiting for the server's reply to request `id`.
|
||||
struct Awaiting {
|
||||
@@ -750,6 +787,43 @@ impl ChatScreen {
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities,
|
||||
layout: SuggestLayout::CHAT,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
}
|
||||
/// `AbstractCommandBlockEditScreen.init`: the command box at the top,
|
||||
/// focused, holding `command`, with suggestions from the start.
|
||||
pub fn command_block(
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
command: &str,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> Self {
|
||||
let mut input = TextField::new(width / 2 - 150, 50, 300, 20);
|
||||
input.max_length = 32500;
|
||||
input.focused = true;
|
||||
input.set_value(m, command);
|
||||
let mut s = Self {
|
||||
input,
|
||||
width,
|
||||
height,
|
||||
history_buffer: String::new(),
|
||||
history_pos: 0,
|
||||
parse: None,
|
||||
pending: None,
|
||||
awaiting: None,
|
||||
request: None,
|
||||
suggestions: None,
|
||||
usage: Vec::new(),
|
||||
usage_position: 0,
|
||||
usage_width: 0,
|
||||
allow_suggestions: true,
|
||||
keep_suggestions: false,
|
||||
auto_suggestions: true,
|
||||
abilities: ChatAbilities::new(ChatVisibility::Full),
|
||||
layout: SuggestLayout::COMMAND_BLOCK,
|
||||
};
|
||||
s.update_command_info(m, c);
|
||||
s
|
||||
@@ -768,7 +842,11 @@ impl ChatScreen {
|
||||
let value = self.input.value.clone();
|
||||
let auto = self.auto_suggestions;
|
||||
let abilities = self.abilities;
|
||||
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
|
||||
*self = if self.layout == SuggestLayout::CHAT {
|
||||
Self::new(m, c, &value, self.history_pos, width, height, abilities)
|
||||
} else {
|
||||
Self::command_block(m, c, &value, width, height)
|
||||
};
|
||||
self.auto_suggestions = auto;
|
||||
}
|
||||
}
|
||||
@@ -814,11 +892,18 @@ impl ChatScreen {
|
||||
self.usage.clear();
|
||||
let cursor = self.input.cursor;
|
||||
let cursor_b = byte_at(&command, cursor);
|
||||
if command.starts_with('/') {
|
||||
let slash = command.starts_with('/');
|
||||
if self.layout.commands_only || slash {
|
||||
let start = usize::from(slash);
|
||||
if self.parse.is_none() {
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, 1, c.registries));
|
||||
self.parse = Some(c.dispatcher.parse_at(&command, start, c.registries));
|
||||
}
|
||||
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let from = if self.layout.only_past_error {
|
||||
start
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if cursor >= from && (self.suggestions.is_none() || !self.keep_suggestions) {
|
||||
let parse = self.parse.as_ref().expect("parsed above");
|
||||
let (range, list, ask) =
|
||||
c.dispatcher
|
||||
@@ -1002,7 +1087,11 @@ impl ChatScreen {
|
||||
0,
|
||||
self.input.screen_x(m, 0) + self.input.inner_width() - width,
|
||||
);
|
||||
let y = self.height - 12;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
self.height - 12
|
||||
} else {
|
||||
72
|
||||
};
|
||||
// sortSuggestions: those starting with the word at the cursor first.
|
||||
let partial = &value[..byte_at(&value, self.input.cursor)];
|
||||
let word = partial[last_word_index(partial)..].to_lowercase();
|
||||
@@ -1011,9 +1100,16 @@ impl ChatScreen {
|
||||
s.text.starts_with(&word) || s.text.starts_with(&format!("minecraft:{word}"))
|
||||
});
|
||||
first.extend(rest);
|
||||
let n = first.len().min(10) as i32;
|
||||
// SuggestionsList: beside a bordered box, or over an unbordered one.
|
||||
let n = first.len().min(self.layout.line_limit) as i32;
|
||||
let bordered = i32::from(self.input.bordered);
|
||||
let top = if self.layout.anchor_to_bottom {
|
||||
y - 3 - n * 12
|
||||
} else {
|
||||
y - bordered
|
||||
};
|
||||
let mut l = SuggestionList {
|
||||
rect: [x - 1, y - 3 - n * 12, width + 1, n * 12],
|
||||
rect: [x - (1 - bordered), top, width + 1, n * 12],
|
||||
original: value,
|
||||
list: first,
|
||||
offset: 0,
|
||||
@@ -1042,11 +1138,12 @@ impl ChatScreen {
|
||||
}
|
||||
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
|
||||
self.select(l, l.current as isize + dir);
|
||||
let max = l.list.len().saturating_sub(10);
|
||||
let limit = self.layout.line_limit;
|
||||
let max = l.list.len().saturating_sub(limit);
|
||||
if l.current < l.offset {
|
||||
l.offset = l.current.min(max);
|
||||
} else if l.current > l.offset + 9 {
|
||||
l.offset = (l.current as isize + 1 - 10).clamp(0, max as isize) as usize;
|
||||
} else if l.current >= l.offset + limit {
|
||||
l.offset = (l.current as isize + 1 - limit as isize).clamp(0, max as isize) as usize;
|
||||
}
|
||||
}
|
||||
/// `SuggestionsList.useSuggestion`.
|
||||
@@ -1074,7 +1171,57 @@ impl ChatScreen {
|
||||
ctrl: bool,
|
||||
) -> ChatAction {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
// CommandSuggestions.keyPressed.
|
||||
if self.suggestions_key(m, c, key, shift) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
self.field_key(m, c, key, shift, ctrl);
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// The input box's own keys (`EditBox.keyPressed`), the suggestions
|
||||
/// following an edit: whether the box took the key.
|
||||
pub fn field_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
ctrl: bool,
|
||||
) -> bool {
|
||||
match self.input.key(m, key, shift, ctrl) {
|
||||
Some(changed) => {
|
||||
if changed {
|
||||
self.edited(m, c);
|
||||
}
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
/// `CommandSuggestions.keyPressed`: the list's arrows, Tab and Escape,
|
||||
/// and Tab to show it. True when the suggestions took the key.
|
||||
pub fn suggestions_key(
|
||||
&mut self,
|
||||
m: &Measure,
|
||||
c: &Commands,
|
||||
key: winit::keyboard::KeyCode,
|
||||
shift: bool,
|
||||
) -> bool {
|
||||
use winit::keyboard::KeyCode as K;
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
let handled = match key {
|
||||
K::ArrowUp | K::ArrowDown => {
|
||||
@@ -1091,37 +1238,18 @@ impl ChatScreen {
|
||||
}
|
||||
K::Escape => {
|
||||
self.input.suggestion = None;
|
||||
return ChatAction::None;
|
||||
return true;
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
self.suggestions = Some(l);
|
||||
if handled {
|
||||
return ChatAction::None;
|
||||
}
|
||||
handled
|
||||
} else if key == K::Tab {
|
||||
self.show_suggestions(m);
|
||||
return ChatAction::None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
match key {
|
||||
K::Escape => return ChatAction::Close,
|
||||
K::Enter | K::NumpadEnter => {
|
||||
// ChatScreen.keyPressed: without `hasAllowedInput` the input
|
||||
// stays, unsent, and the screen stays open.
|
||||
if !self.abilities.allows(&self.input.value) {
|
||||
return ChatAction::None;
|
||||
}
|
||||
return ChatAction::Submit(self.normalized());
|
||||
}
|
||||
K::ArrowUp => self.move_in_history(m, c, history, -1),
|
||||
K::ArrowDown => self.move_in_history(m, c, history, 1),
|
||||
_ => {
|
||||
if self.input.key(m, key, shift, ctrl) == Some(true) {
|
||||
self.edited(m, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatAction::None
|
||||
}
|
||||
/// `ChatScreen.moveInHistory`.
|
||||
fn move_in_history(&mut self, m: &Measure, c: &Commands, history: &[String], dir: isize) {
|
||||
@@ -1175,7 +1303,7 @@ impl ChatScreen {
|
||||
if !l.contains(mouse.0, mouse.1) {
|
||||
return false;
|
||||
}
|
||||
let max = l.list.len().saturating_sub(10) as i32;
|
||||
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
|
||||
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
|
||||
true
|
||||
}
|
||||
@@ -1246,7 +1374,9 @@ impl ChatScreen {
|
||||
) -> Vec<Draw> {
|
||||
let (w, h) = (self.width, self.height);
|
||||
let mut out = Vec::new();
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
if self.layout.anchor_to_bottom {
|
||||
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
|
||||
}
|
||||
let format = |s: &str, o: usize| self.format(s, o);
|
||||
self.input.draw(&mut out, m, blink_ms, Some(&format));
|
||||
if let Some(mut l) = self.suggestions.take() {
|
||||
@@ -1254,14 +1384,18 @@ impl ChatScreen {
|
||||
self.suggestions = Some(l);
|
||||
} else {
|
||||
for (i, line) in self.usage.iter().enumerate() {
|
||||
let y = h - 27 - 12 * i as i32;
|
||||
let y = if self.layout.anchor_to_bottom {
|
||||
h - 27 - 12 * i as i32
|
||||
} else {
|
||||
72 + 12 * i as i32
|
||||
};
|
||||
d::fill(
|
||||
&mut out,
|
||||
self.usage_position - 1,
|
||||
y,
|
||||
self.usage_position + self.usage_width + 1,
|
||||
y + 12,
|
||||
FILL,
|
||||
self.layout.fill,
|
||||
);
|
||||
d::text(&mut out, line.clone(), self.usage_position, y + 2, -1, true);
|
||||
}
|
||||
@@ -1275,7 +1409,7 @@ impl ChatScreen {
|
||||
l: &mut SuggestionList,
|
||||
mouse: (i32, i32),
|
||||
) {
|
||||
let limit = l.list.len().min(10);
|
||||
let limit = l.list.len().min(self.layout.line_limit);
|
||||
let [rx, ry, rw, rh] = l.rect;
|
||||
let has_prev = l.offset > 0;
|
||||
let has_next = l.list.len() > l.offset + limit;
|
||||
@@ -1284,8 +1418,8 @@ impl ChatScreen {
|
||||
l.last_mouse = mouse;
|
||||
}
|
||||
if has_prev || has_next {
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, FILL);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, FILL);
|
||||
d::fill(out, rx, ry - 1, rx + rw, ry, self.layout.fill);
|
||||
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, self.layout.fill);
|
||||
if has_prev {
|
||||
for x in (0..rw).step_by(2) {
|
||||
d::fill(out, rx + x, ry - 1, rx + x + 1, ry, -1);
|
||||
@@ -1300,7 +1434,7 @@ impl ChatScreen {
|
||||
let mut hovered = false;
|
||||
for i in 0..limit {
|
||||
let y = ry + 12 * i as i32;
|
||||
d::fill(out, rx, y, rx + rw, y + 12, FILL);
|
||||
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
|
||||
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
|
||||
if moved {
|
||||
self.select(l, (i + l.offset) as isize);
|
||||
|
||||
@@ -486,6 +486,24 @@ pub fn create_experience(
|
||||
xp
|
||||
}
|
||||
|
||||
/// The `MenuType` the block entity of a one-block storage container opens
|
||||
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
|
||||
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
|
||||
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
|
||||
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
|
||||
pub fn block_menu_type(block: &str) -> Option<&'static str> {
|
||||
Some(match block {
|
||||
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
|
||||
"minecraft:generic_9x3"
|
||||
}
|
||||
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
|
||||
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
|
||||
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
|
||||
"minecraft:hopper" => "minecraft:hopper",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Written to `crash-reports/crash-<yyyy-MM-dd_HH.mm.ss>-client.txt` in the game folder when
|
||||
//! the main thread panics or `main` gets an error back. It leaves the machine only scrubbed, and
|
||||
//! only if the player allows crash reports (`telemetry`).
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
@@ -14,6 +15,51 @@ use chrono::{DateTime, Local};
|
||||
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
|
||||
static REPORTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
thread_local! {
|
||||
/// How many `contain` calls this thread is inside.
|
||||
static CONTAINED: Cell<u32> = const { Cell::new(0) };
|
||||
/// The message of the last panic `contain` caught on this thread, from the hook.
|
||||
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
|
||||
/// it end the game. For work that is not the game's own and whose failure the caller recovers
|
||||
/// from, such as handing a shader pack's programs to the GPU driver's compiler: the panic goes
|
||||
/// to the log with its stack, and no crash report is made for it.
|
||||
pub fn contain<T>(f: impl FnOnce() -> T) -> Result<T, String> {
|
||||
CONTAINED.with(|c| c.set(c.get() + 1));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
CONTAINED.with(|c| c.set(c.get() - 1));
|
||||
result.map_err(|payload| {
|
||||
CAUGHT
|
||||
.with(|c| c.borrow_mut().take())
|
||||
.unwrap_or_else(|| payload_text(&*payload))
|
||||
})
|
||||
}
|
||||
|
||||
/// For the hook: a panic inside `contain` is logged with its stack and kept for `contain` to
|
||||
/// return. False for any other panic.
|
||||
fn contained_panic(info: &std::panic::PanicHookInfo) -> bool {
|
||||
if CONTAINED.with(Cell::get) == 0 {
|
||||
return false;
|
||||
}
|
||||
let message = panic_message(info);
|
||||
let stack = stack_lines(
|
||||
&std::backtrace::Backtrace::force_capture().to_string(),
|
||||
true,
|
||||
);
|
||||
log::error!(
|
||||
"Recovered from an error: {message}\n{}",
|
||||
stack
|
||||
.lines()
|
||||
.map(|l| format!("\tat {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
CAUGHT.with(|c| *c.borrow_mut() = Some(message));
|
||||
true
|
||||
}
|
||||
|
||||
/// Sets one line of the report's details (replacing the last value under `key`).
|
||||
pub fn set_detail(key: &'static str, value: impl Into<String>) {
|
||||
let mut details = DETAILS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
@@ -327,13 +373,16 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
)
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let payload = info.payload();
|
||||
let text = payload
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into());
|
||||
.unwrap_or_else(|| "Box<dyn Any>".into())
|
||||
}
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let text = payload_text(info.payload());
|
||||
match info.location() {
|
||||
Some(at) => format!("panicked at {at}:\n{text}"),
|
||||
None => format!("panicked:\n{text}"),
|
||||
@@ -436,10 +485,13 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
|
||||
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic.
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if contained_panic(info) {
|
||||
return;
|
||||
}
|
||||
default(info);
|
||||
let main = std::thread::current().name() == Some("main");
|
||||
if main {
|
||||
@@ -628,6 +680,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contained_panics_come_back_as_errors() {
|
||||
assert_eq!(contain(|| 7), Ok(7));
|
||||
let error = contain(|| -> u8 { panic!("a pack's program") }).unwrap_err();
|
||||
assert!(error.contains("a pack's program"), "{error}");
|
||||
// Nested, and the thread is no longer inside one after.
|
||||
let outer = contain(|| contain(|| -> u8 { panic!("inner") }));
|
||||
assert!(outer.unwrap().unwrap_err().contains("inner"));
|
||||
assert_eq!(CONTAINED.with(Cell::get), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_debug_crash_is_reported_as_vanillas() {
|
||||
let error: Box<dyn std::error::Error> = DebugCrash::new().into();
|
||||
|
||||
@@ -72,9 +72,8 @@ pub struct Extras {
|
||||
pub zoom_smooth: bool,
|
||||
/// Divide mouse turning by the zoom.
|
||||
pub zoom_sensitivity: bool,
|
||||
/// The FPS counter and keystrokes extras are official mods now
|
||||
/// (`moved_extras`): their old settings stay here until the game has
|
||||
/// installed the mod for a player who had one on.
|
||||
/// The FPS counter and keystrokes extras are official mods now: their
|
||||
/// old settings stay here, kept as they were.
|
||||
pub fps: bool,
|
||||
pub fps_corner: Corner,
|
||||
/// Also show the minimum and 1% low.
|
||||
@@ -83,8 +82,8 @@ pub struct Extras {
|
||||
pub keystrokes_corner: Corner,
|
||||
/// Clicks per second under the mouse buttons.
|
||||
pub keystrokes_cps: bool,
|
||||
/// The official mods installed for the extras they replace, by mod id
|
||||
/// (`movedToMods`): each is done once.
|
||||
/// The official mods once installed for the extras they replace, by mod
|
||||
/// id (`movedToMods`).
|
||||
pub moved: Vec<String>,
|
||||
pub minimap: bool,
|
||||
pub minimap_corner: Corner,
|
||||
@@ -97,6 +96,8 @@ pub struct Extras {
|
||||
pub minimap_rotate: bool,
|
||||
/// Retry lost multiplayer connections (`reconnect`).
|
||||
pub auto_reconnect: bool,
|
||||
/// The player's own name tag over its model in third person.
|
||||
pub own_name_tag: bool,
|
||||
/// Mouse Tweaks-style drags in the inventory and storage containers.
|
||||
pub mouse_tweaks: bool,
|
||||
/// With them, the wheel over a slot moves its items one at a time.
|
||||
@@ -137,6 +138,7 @@ impl Default for Extras {
|
||||
minimap_zoom: 1.0,
|
||||
minimap_rotate: false,
|
||||
auto_reconnect: false,
|
||||
own_name_tag: false,
|
||||
mouse_tweaks: false,
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
@@ -211,6 +213,7 @@ impl Extras {
|
||||
}
|
||||
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
|
||||
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
|
||||
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
|
||||
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
@@ -225,7 +228,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -251,6 +254,7 @@ impl Extras {
|
||||
self.minimap_zoom,
|
||||
bool_text(self.minimap_rotate),
|
||||
bool_text(self.auto_reconnect),
|
||||
bool_text(self.own_name_tag),
|
||||
bool_text(self.mouse_tweaks),
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
@@ -282,7 +286,7 @@ impl Extras {
|
||||
}
|
||||
|
||||
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
|
||||
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`native_zoom`). A
|
||||
/// zoom of 1 leaves it as is.
|
||||
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
|
||||
if zoom == 1.0 { vanilla } else { vanilla / zoom }
|
||||
@@ -378,6 +382,7 @@ impl Cps {
|
||||
pub enum Item {
|
||||
Fullbright,
|
||||
DynamicLights,
|
||||
OwnNameTag,
|
||||
AutoReconnect,
|
||||
MouseTweaks,
|
||||
MouseTweaksWheel,
|
||||
@@ -396,6 +401,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
@@ -410,6 +417,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
Item::DynamicLights => "Dynamic lights",
|
||||
Item::OwnNameTag => "Own name tag",
|
||||
Item::AutoReconnect => "Auto-reconnect",
|
||||
Item::MouseTweaks => "Mouse Tweaks controls",
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
@@ -426,7 +434,7 @@ impl Item {
|
||||
Some(match self {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
|
||||
| AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
@@ -439,6 +447,9 @@ impl Item {
|
||||
Item::DynamicLights => {
|
||||
"Torches and other light sources held, dropped or on fire light the blocks around them."
|
||||
}
|
||||
Item::OwnNameTag => {
|
||||
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
|
||||
}
|
||||
Item::AutoReconnect => {
|
||||
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
|
||||
}
|
||||
@@ -471,7 +482,9 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
|
||||
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
|
||||
Some(Cost::None)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -480,6 +493,7 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Fullbright => e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
@@ -506,6 +520,7 @@ impl Item {
|
||||
match self {
|
||||
Item::Fullbright => e.fullbright = !e.fullbright,
|
||||
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
|
||||
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
|
||||
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
@@ -534,7 +549,7 @@ mod tests {
|
||||
let d = Extras::default();
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort);
|
||||
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
@@ -558,6 +573,8 @@ mod tests {
|
||||
e.minimap_rotate = true;
|
||||
e.minimap_corner = Corner::BottomLeft;
|
||||
e.auto_reconnect = true;
|
||||
Item::OwnNameTag.cycle(&mut e, false);
|
||||
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
|
||||
e.mouse_tweaks = true;
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
|
||||
@@ -70,6 +70,7 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
|
||||
|
||||
mod animate;
|
||||
mod arena;
|
||||
pub mod compositor;
|
||||
mod cull;
|
||||
mod end_sky;
|
||||
mod flat;
|
||||
@@ -93,6 +94,7 @@ pub mod pack_uniforms;
|
||||
mod pack_weather;
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod stars;
|
||||
mod sunrise_fan;
|
||||
@@ -355,6 +357,14 @@ struct BorderUniform {
|
||||
color: [f32; 4],
|
||||
offset: [f32; 4],
|
||||
}
|
||||
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
|
||||
/// or the icon the Select World list shows.
|
||||
pub struct ScreenshotRequest {
|
||||
pub path: PathBuf,
|
||||
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
|
||||
/// and average it down to this size. `None` saves the frame as drawn.
|
||||
pub size: Option<[u32; 2]>,
|
||||
}
|
||||
pub struct Renderer {
|
||||
instance: wgpu::Instance,
|
||||
adapter: wgpu::Adapter,
|
||||
@@ -382,6 +392,11 @@ pub struct Renderer {
|
||||
pub last_wait: std::time::Duration,
|
||||
/// CPU time of the last `render`, waits excluded.
|
||||
pub last_encode: std::time::Duration,
|
||||
/// What the system says about the window's display, watched only while HDR output
|
||||
/// is on; before `window`, so it's dropped first.
|
||||
display: DisplayWatch,
|
||||
/// The watch was just made: the settings' Auto values take what it reported.
|
||||
display_fresh: bool,
|
||||
window: Arc<Window>,
|
||||
pub device: wgpu::Device,
|
||||
pub queue: wgpu::Queue,
|
||||
@@ -568,6 +583,8 @@ pub struct Renderer {
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
/// Where a screenshot's frame is drawn when the swapchain cannot be read back.
|
||||
readback: readback::Readback,
|
||||
pub info: wgpu::AdapterInfo,
|
||||
pub suspended: bool,
|
||||
pub drawn_sections: usize,
|
||||
@@ -577,8 +594,8 @@ pub struct Renderer {
|
||||
pub section_draw_calls: usize,
|
||||
pub uploaded_bytes: u64,
|
||||
started: Instant,
|
||||
/// Write the next presented frame to this PNG path (control socket).
|
||||
pub screenshot: Option<PathBuf>,
|
||||
/// The next frame to write to a PNG path (control socket, F2, world icon).
|
||||
pub screenshot: Option<ScreenshotRequest>,
|
||||
/// Outcome of the last screenshot: frame size, or the error.
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// Brightness option (lightmap gamma), 0..1.
|
||||
@@ -1170,6 +1187,7 @@ impl Renderer {
|
||||
}
|
||||
surface.configure(&device, &config);
|
||||
let hdr_output = hdr::output(&caps);
|
||||
crate::graphics::offer_hdr(hdr_output.is_some());
|
||||
if previous.is_none() {
|
||||
log::info!(
|
||||
"Horizon renderer: HDR output {}",
|
||||
@@ -2582,6 +2600,8 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
display: DisplayWatch::default(),
|
||||
display_fresh: false,
|
||||
instance,
|
||||
adapter,
|
||||
surface,
|
||||
@@ -2724,6 +2744,7 @@ impl Renderer {
|
||||
pack_terrain_flat: false,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
readback: readback::Readback::default(),
|
||||
info,
|
||||
suspended: !drawable_size(size.width, size.height, max_side),
|
||||
drawn_sections: 0,
|
||||
@@ -3243,7 +3264,7 @@ impl Renderer {
|
||||
) {
|
||||
surface.configure(device, config);
|
||||
if let Some(hdr) = hdr {
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak);
|
||||
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
}
|
||||
}
|
||||
fn configure_surface(&self) {
|
||||
@@ -3258,7 +3279,7 @@ impl Renderer {
|
||||
}
|
||||
fn send_hdr_metadata(&self) {
|
||||
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak);
|
||||
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak, hdr.black);
|
||||
}
|
||||
}
|
||||
/// The swapchain's configuration: `config` (what every pipeline draws in), or
|
||||
@@ -3288,7 +3309,15 @@ impl Renderer {
|
||||
}
|
||||
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
|
||||
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
|
||||
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32, marker: &std::path::Path) {
|
||||
pub fn set_hdr(
|
||||
&mut self,
|
||||
on: bool,
|
||||
paper_white: f32,
|
||||
peak: f32,
|
||||
black: f32,
|
||||
ui: f32,
|
||||
marker: &std::path::Path,
|
||||
) {
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
if output.is_some() != self.hdr.is_some() {
|
||||
// If the game dies before HDR output shows a frame, the marker is still there at
|
||||
@@ -3299,6 +3328,16 @@ impl Renderer {
|
||||
} else if let Some(marker) = self.hdr_marker.take() {
|
||||
let _ = std::fs::remove_file(marker);
|
||||
}
|
||||
// Only HDR output asks the system about the display, so with HDR off the
|
||||
// game never waits on the compositor for it.
|
||||
self.display = if output.is_some() {
|
||||
let (watch, now) = DisplayWatch::new(&self.window);
|
||||
crate::graphics::report(now);
|
||||
self.display_fresh = true;
|
||||
watch
|
||||
} else {
|
||||
DisplayWatch::default()
|
||||
};
|
||||
let format = self.config.format.remove_srgb_suffix();
|
||||
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
|
||||
self.configure_surface();
|
||||
@@ -3311,16 +3350,35 @@ impl Renderer {
|
||||
None => log::info!("Horizon renderer: HDR off"),
|
||||
}
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.ui = ui;
|
||||
}
|
||||
if let Some(hdr) = &mut self.hdr
|
||||
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
|
||||
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
|
||||
{
|
||||
hdr.paper_white = paper_white;
|
||||
hdr.peak = peak;
|
||||
hdr.black = black;
|
||||
self.send_hdr_metadata();
|
||||
}
|
||||
}
|
||||
/// Whether what the system reports for the display changed (another output, the
|
||||
/// player recalibrating the desktop): the settings' Auto values follow it then, so
|
||||
/// apply them again.
|
||||
pub fn display_changed(&mut self) -> bool {
|
||||
flush_display_log();
|
||||
if std::mem::take(&mut self.display_fresh) {
|
||||
return true;
|
||||
}
|
||||
let Some(now) = self.display.changed() else {
|
||||
return false;
|
||||
};
|
||||
crate::graphics::report(now);
|
||||
true
|
||||
}
|
||||
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
|
||||
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
|
||||
let taa = taa && self.world_post.supports_taa();
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias && !taa;
|
||||
self.world_post.taa = taa;
|
||||
@@ -3678,7 +3736,12 @@ impl Renderer {
|
||||
let size = [self.config.width, self.config.height];
|
||||
let target_texture = match (&surface_frame, &mut self.hdr) {
|
||||
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
|
||||
(Some(f), None) => f.texture.clone(),
|
||||
// A screenshot of a swapchain without COPY_SRC (OpenGL) draws
|
||||
// into a texture that has it, then onto the swapchain.
|
||||
(Some(f), None) => self
|
||||
.readback
|
||||
.target(&self.device, &f.texture, self.screenshot.is_some())
|
||||
.unwrap_or_else(|| f.texture.clone()),
|
||||
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
|
||||
};
|
||||
// One command buffer for the frame, after the section copies staged
|
||||
@@ -5200,6 +5263,10 @@ impl Renderer {
|
||||
((world_quads + additive_quads) * 4) as i32,
|
||||
0..1,
|
||||
);
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(&mut encoder);
|
||||
}
|
||||
}
|
||||
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
|
||||
self.entity_outline.draw(
|
||||
@@ -5208,6 +5275,13 @@ impl Renderer {
|
||||
&self.entity_group,
|
||||
&self.indices,
|
||||
);
|
||||
// The world icon grabs here: after the level and the hand, before
|
||||
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
|
||||
// runs right after `renderLevel`).
|
||||
let readback = self
|
||||
.screenshot
|
||||
.take_if(|request| request.size.is_some())
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
|
||||
if !frame.screen.is_empty() {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view_target,
|
||||
@@ -5311,24 +5385,20 @@ impl Renderer {
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
let readback = self.screenshot.take().and_then(|path| {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
Some((path, self.copy_frame(&mut encoder, &target_texture)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
let readback = readback.or_else(|| {
|
||||
self.screenshot
|
||||
.take()
|
||||
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
|
||||
});
|
||||
self.uploads.finish(&encoder);
|
||||
if has_section_copies {
|
||||
self.section_copy_bytes = 0;
|
||||
self.section_uploads.finish(&encoder);
|
||||
}
|
||||
if let Some(f) = &surface_frame {
|
||||
self.readback
|
||||
.present(&self.device, &mut encoder, &f.texture);
|
||||
}
|
||||
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
|
||||
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
|
||||
}
|
||||
@@ -5349,9 +5419,9 @@ impl Renderer {
|
||||
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
|
||||
timer.submitted(&self.queue);
|
||||
}
|
||||
if let Some((path, buffer)) = readback {
|
||||
if let Some((request, buffer)) = readback {
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&path, &buffer)
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
@@ -5401,43 +5471,68 @@ impl Renderer {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
|
||||
fn padded_row(width: u32) -> u32 {
|
||||
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
/// Encodes the copy of `texture`'s `size` pixels into a mappable buffer.
|
||||
fn copy_frame(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
[width, height]: [u32; 2],
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon screenshot"),
|
||||
size: u64::from(padded_row(width)) * u64::from(height),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(padded_row(width)),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
fn padded_row(&self) -> u32 {
|
||||
(self.config.width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
|
||||
}
|
||||
fn copy_frame(
|
||||
&self,
|
||||
/// Take a screenshot request: queue the frame's copy, or report why the
|
||||
/// frame cannot be read back.
|
||||
fn take_readback(
|
||||
&mut self,
|
||||
request: ScreenshotRequest,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon screenshot"),
|
||||
size: u64::from(self.padded_row()) * u64::from(self.config.height),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(self.padded_row()),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: self.config.width,
|
||||
height: self.config.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
buffer
|
||||
}
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return Err("this surface does not support frame readback".into());
|
||||
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
|
||||
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
|
||||
// the row stride too small for the copy and abort in validation.
|
||||
if self.config.format.block_copy_size(None) == Some(4) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
None
|
||||
}
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
@@ -5452,7 +5547,7 @@ impl Renderer {
|
||||
let (width, height, row) = (
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
self.padded_row() as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
@@ -5472,8 +5567,18 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
@@ -5483,6 +5588,42 @@ impl Renderer {
|
||||
Ok([width, height])
|
||||
}
|
||||
}
|
||||
/// Centre-crop the frame to a square and average it down to `size`.
|
||||
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
|
||||
let (side, cx, cy) = if width > height {
|
||||
(height, (width - height) / 2, 0)
|
||||
} else {
|
||||
(width, 0, (height - width) / 2)
|
||||
};
|
||||
let [tw, th] = size;
|
||||
let mut out = vec![0u8; (tw * th * 4) as usize];
|
||||
for ty in 0..th {
|
||||
let y0 = cy + ty * side / th;
|
||||
let y1 = cy + (ty + 1) * side / th;
|
||||
for tx in 0..tw {
|
||||
let x0 = cx + tx * side / tw;
|
||||
let x1 = cx + (tx + 1) * side / tw;
|
||||
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let p = ((y * width + x) * 4) as usize;
|
||||
r += u32::from(pixels[p]);
|
||||
g += u32::from(pixels[p + 1]);
|
||||
b += u32::from(pixels[p + 2]);
|
||||
}
|
||||
}
|
||||
let count = (x1 - x0) * (y1 - y0);
|
||||
let o = ((ty * tw + tx) * 4) as usize;
|
||||
out[o..o + 4].copy_from_slice(&[
|
||||
(r / count) as u8,
|
||||
(g / count) as u8,
|
||||
(b / count) as u8,
|
||||
255,
|
||||
]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Camera-relative clip planes (`Frustum`).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Frustum {
|
||||
@@ -6013,3 +6154,101 @@ mod sprite_upload_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the system says about the window's display, kept up to date: on Wayland, the
|
||||
/// compositor's preferred image description of the surface.
|
||||
#[derive(Default)]
|
||||
struct DisplayWatch {
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: Option<horizon_hdr::wayland::Watch>,
|
||||
}
|
||||
impl DisplayWatch {
|
||||
/// Starts watching, with what the system reports now.
|
||||
fn new(window: &Window) -> (Self, Option<crate::graphics::Reported>) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) {
|
||||
let watch = Self {
|
||||
wayland: Some(wayland),
|
||||
};
|
||||
return (watch, now.map(reported));
|
||||
}
|
||||
let _ = window;
|
||||
(Self::default(), None)
|
||||
}
|
||||
/// What the system reports, if that changed since the last call.
|
||||
fn changed(&mut self) -> Option<Option<crate::graphics::Reported>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(wayland) = &mut self.wayland {
|
||||
return wayland.changed().map(|now| now.map(reported));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The log of what the system reports about the display: one line per change. The
|
||||
/// renderer is made anew a few times while the game starts and each asks again, and a
|
||||
/// slider dragged in the desktop's settings changes the values many times a second, so
|
||||
/// a change within [`DISPLAY_LOG_QUIET`] of the last line waits until the values have
|
||||
/// held that long, and only the one they settled on is logged.
|
||||
struct DisplayLog {
|
||||
last: String,
|
||||
at: Option<std::time::Instant>,
|
||||
pending: Option<(String, std::time::Instant)>,
|
||||
}
|
||||
static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(DisplayLog {
|
||||
last: String::new(),
|
||||
at: None,
|
||||
pending: None,
|
||||
});
|
||||
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
fn log_display(line: String) {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if line == log.last {
|
||||
log.pending = None;
|
||||
} else if log.at.is_none_or(|at| now - at >= DISPLAY_LOG_QUIET) {
|
||||
log::info!("{line}");
|
||||
(log.last, log.at, log.pending) = (line, Some(now), None);
|
||||
} else {
|
||||
log.pending = Some((line, now));
|
||||
}
|
||||
}
|
||||
/// Logs a change [`log_display`] held back, once the values have held for
|
||||
/// [`DISPLAY_LOG_QUIET`].
|
||||
fn flush_display_log() {
|
||||
let now = std::time::Instant::now();
|
||||
let mut log = DISPLAY_LOG
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some((_, since)) = &log.pending
|
||||
&& now - *since >= DISPLAY_LOG_QUIET
|
||||
&& let Some((line, _)) = log.pending.take()
|
||||
{
|
||||
log::info!("{line}");
|
||||
(log.last, log.at) = (line, Some(now));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a compositor's luminances mean in HDR output's scale.
|
||||
///
|
||||
/// KWin maps content's reference white (203 nits in PQ, BT.2408) onto its own, so the
|
||||
/// desktop's white is 203 there and the peak is the target's maximum over that ratio.
|
||||
/// Checked on KWin. Hyprland (0.57) reports the standard 203, and shows HDR windows below
|
||||
/// the SDR white of its desktop (which it doesn't report), so Auto can't match its desktop
|
||||
/// there. Other compositors are unchecked. The peak is kept within the settings' lowest and
|
||||
/// PQ's top, so the metadata never ends below the black level.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
|
||||
let peak = (l.target_max * 203.0 / l.reference.max(1.0)).round() as u32;
|
||||
log_display(format!(
|
||||
"Horizon renderer: the compositor shows this window with reference white {} nits, \
|
||||
{}..{} nits: peak {peak} nits in HDR output's scale",
|
||||
l.reference, l.target_min, l.target_max,
|
||||
));
|
||||
crate::graphics::Reported {
|
||||
paper_white: 203,
|
||||
peak: peak.clamp(200, 10_000),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Splitscreen: several players' renderers draw offscreen on one device, and the
|
||||
//! compositor copies each frame into its place on a window's surface. A window holds views but
|
||||
//! never owns them, so a view can later move to another window (another monitor, detached).
|
||||
use super::Renderer;
|
||||
use crate::Result;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
pub struct Compositor {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
present_modes: Vec<wgpu::PresentMode>,
|
||||
suspended: bool,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// A compositor for `window` on this renderer's device, presenting on the surface it gave up.
|
||||
pub fn compositor(&self, window: Arc<Window>) -> Result<Compositor> {
|
||||
// The surface this renderer set aside for its successor (`release_surface`), else a new
|
||||
// one: a window can't keep two.
|
||||
let spare = self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take();
|
||||
let surface = match spare {
|
||||
Some(surface) => surface,
|
||||
None => self.instance.create_surface(window.clone())?,
|
||||
};
|
||||
Compositor::new(
|
||||
surface,
|
||||
&self.adapter,
|
||||
&self.device,
|
||||
&self.queue,
|
||||
window.inner_size().into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The last offscreen frame (`--offscreen`), for the compositor.
|
||||
pub fn offscreen_frame(&self) -> Option<&wgpu::Texture> {
|
||||
self.offscreen.as_ref()
|
||||
}
|
||||
|
||||
/// Splitscreen turned off: a compositor's surface, set aside for the renderer that draws on
|
||||
/// the window again (it takes it, as after `release_surface`).
|
||||
pub fn take_back(&self, compositor: Compositor) {
|
||||
*self
|
||||
.spare_surface
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(compositor.surface);
|
||||
}
|
||||
}
|
||||
|
||||
impl Compositor {
|
||||
/// Present offscreen BGRA frames on a compatible surface using their existing device.
|
||||
/// The surface must support COPY_DST and Bgra8Unorm; unsupported surfaces return an error.
|
||||
pub fn new(
|
||||
surface: wgpu::Surface<'static>,
|
||||
adapter: &wgpu::Adapter,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
size: [u32; 2],
|
||||
) -> Result<Self> {
|
||||
let caps = surface.get_capabilities(adapter);
|
||||
// ponytail: a plain texture copy needs the offscreen frames' format; a blit pass would
|
||||
// take any surface format (Rgba8Unorm on some Linux drivers).
|
||||
if !caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
|
||||
return Err("surface does not support copying offscreen frames".into());
|
||||
}
|
||||
let format = wgpu::TextureFormat::Bgra8Unorm;
|
||||
if !caps.formats.contains(&format) {
|
||||
return Err(format!(
|
||||
"splitscreen needs a {format:?} surface; this one offers {:?}",
|
||||
caps.formats
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
|
||||
format,
|
||||
width: size[0].clamp(1, max),
|
||||
height: size[1].clamp(1, max),
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
desired_maximum_frame_latency: 2,
|
||||
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
|
||||
view_formats: vec![],
|
||||
color_space: wgpu::SurfaceColorSpace::default(),
|
||||
};
|
||||
surface.configure(device, &config);
|
||||
Ok(Compositor {
|
||||
surface,
|
||||
device: device.clone(),
|
||||
queue: queue.clone(),
|
||||
config,
|
||||
present_modes: caps.present_modes,
|
||||
suspended: !super::drawable_size(size[0], size[1], max),
|
||||
})
|
||||
}
|
||||
|
||||
/// Vsync on: presents wait for the display. Off: they don't (Mailbox, else Immediate).
|
||||
pub fn set_vsync(&mut self, vsync: bool) {
|
||||
let mode = [wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate]
|
||||
.into_iter()
|
||||
.find(|m| !vsync && self.present_modes.contains(m))
|
||||
.unwrap_or(wgpu::PresentMode::Fifo);
|
||||
if mode != self.config.present_mode {
|
||||
self.config.present_mode = mode;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.suspended =
|
||||
!super::drawable_size(width, height, self.device.limits().max_texture_dimension_2d);
|
||||
if !self.suspended {
|
||||
self.config.width = width;
|
||||
self.config.height = height;
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy each view's frame to its top-left corner on the window and present. Frames that
|
||||
/// don't fit are clipped; parts of the window no view covers (a view before its first frame,
|
||||
/// one still at its old size after a resize) are black. Nothing when the window has no frame
|
||||
/// to give (occluded, resized, timed out). Returns true when a frame was submitted.
|
||||
pub fn present(&mut self, views: &[(&wgpu::Texture, [u32; 2])]) -> bool {
|
||||
if self.suspended {
|
||||
return false;
|
||||
}
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(f)
|
||||
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
|
||||
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return false;
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("Splitscreen compositor"),
|
||||
});
|
||||
let target = frame.texture.create_view(&Default::default());
|
||||
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Splitscreen compositor clear"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
}));
|
||||
for &(texture, [x, y]) in views {
|
||||
let [width, height] = clipped_size(
|
||||
[texture.width(), texture.height()],
|
||||
[x, y],
|
||||
[self.config.width, self.config.height],
|
||||
);
|
||||
if width == 0 || height == 0 {
|
||||
continue;
|
||||
}
|
||||
encoder.copy_texture_to_texture(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
origin: wgpu::Origin3d { x, y, z: 0 },
|
||||
..frame.texture.as_image_copy()
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.queue.submit([encoder.finish()]);
|
||||
self.queue.present(frame);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies never reach past the surface, including while a resized view still has its old frame.
|
||||
fn clipped_size(view: [u32; 2], at: [u32; 2], surface: [u32; 2]) -> [u32; 2] {
|
||||
[
|
||||
view[0].min(surface[0].saturating_sub(at[0])),
|
||||
view[1].min(surface[1].saturating_sub(at[1])),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frames_are_clipped_to_the_window_during_resize() {
|
||||
assert_eq!(clipped_size([640, 480], [0, 0], [1280, 720]), [640, 480]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [1000, 600], [1280, 720]),
|
||||
[280, 120]
|
||||
);
|
||||
assert_eq!(clipped_size([640, 480], [1280, 720], [1280, 720]), [0, 0]);
|
||||
assert_eq!(
|
||||
clipped_size([640, 480], [u32::MAX, 0], [1280, 720]),
|
||||
[0, 480]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,12 @@ struct Targets {
|
||||
pub struct Hdr {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
|
||||
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
|
||||
clear_alpha: wgpu::RenderPipeline,
|
||||
/// Scales the frame's alpha down to 1/255 where the hand drew (`mark_hand`), so HDR
|
||||
/// output keeps the hand at the world's brightness.
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
/// The frame's format: what every other pipeline draws in.
|
||||
@@ -73,6 +79,10 @@ pub struct Hdr {
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
|
||||
pub black: f32,
|
||||
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
|
||||
pub ui: f32,
|
||||
}
|
||||
|
||||
impl Hdr {
|
||||
@@ -138,6 +148,48 @@ impl Hdr {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&device.create_pipeline_layout(&Default::default())),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_hdr"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_clear_alpha"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let clear_alpha = alpha_pass("Horizon HDR UI mask", None);
|
||||
// Alpha times the blend constant (1/255): the hand's 1 becomes 1/255, the world's 0
|
||||
// stays 0.
|
||||
let scale_alpha = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::Zero,
|
||||
dst_factor: wgpu::BlendFactor::Constant,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let hand_mark = alpha_pass(
|
||||
"Horizon HDR hand mark",
|
||||
Some(wgpu::BlendState {
|
||||
color: scale_alpha,
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 32,
|
||||
@@ -147,6 +199,8 @@ impl Hdr {
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params,
|
||||
output,
|
||||
format,
|
||||
@@ -154,6 +208,8 @@ impl Hdr {
|
||||
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
ui: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +266,9 @@ impl Hdr {
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it.
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
@@ -220,6 +278,56 @@ impl Hdr {
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR UI mask"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.clear_alpha);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR hand mark"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.hand_mark);
|
||||
let a = 1.0 / 255.0;
|
||||
pass.set_blend_constant(wgpu::Color {
|
||||
r: a,
|
||||
g: a,
|
||||
b: a,
|
||||
a,
|
||||
});
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// Writes the finished frame to the swapchain image `target`.
|
||||
@@ -240,8 +348,8 @@ impl Hdr {
|
||||
f32::from(u8::from(world)),
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
0.0,
|
||||
0.0,
|
||||
self.black.clamp(0.0, paper),
|
||||
self.ui.clamp(0.25, 1.0),
|
||||
0.0,
|
||||
];
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
|
||||
@@ -15,10 +15,19 @@ pub struct Bolt {
|
||||
#[derive(Default)]
|
||||
pub struct Input<'a> {
|
||||
pub bolts: &'a [Bolt],
|
||||
/// Current atlas white texel; a changed rect invalidates retained vertices.
|
||||
/// Current atlas white texel ([`white_uv`]); a changed rect invalidates
|
||||
/// retained vertices.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
/// The atlas point an untextured quad (`RenderTypes.lightning` has no
|
||||
/// texture) samples: the middle of the white sprite. Its corner (`u0`, `v0`)
|
||||
/// is on the sprite's border, where the atlas's nearest sampling picks the
|
||||
/// neighbouring, transparent texels for most fragments.
|
||||
pub fn white_uv(white: &crate::hud::Rect) -> [f32; 2] {
|
||||
[(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0]
|
||||
}
|
||||
|
||||
// Four layers, fourteen segments (eight trunk, two branches of three), four quads.
|
||||
pub(super) const QUADS: u32 = 4 * 14 * 4;
|
||||
|
||||
|
||||
@@ -496,3 +496,253 @@ fn retained_gpu_draw_matches_transient_additive_draw() {
|
||||
assert_eq!(pixels[0], pixels[1], "frame {frame}: GPU output differs");
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon/white` (4x4 texels) at (40, 12) of a 64x32 atlas.
|
||||
const ATLAS: (u32, u32) = (64, 32);
|
||||
fn white_sprite() -> crate::hud::Rect {
|
||||
let (w, h) = (ATLAS.0 as f32, ATLAS.1 as f32);
|
||||
crate::hud::Rect {
|
||||
u0: 40.0 / w,
|
||||
v0: 12.0 / h,
|
||||
u1: 44.0 / w,
|
||||
v1: 16.0 / h,
|
||||
w: 4,
|
||||
h: 4,
|
||||
}
|
||||
}
|
||||
|
||||
/// The white sprite's corner is on its border with the next atlas texels,
|
||||
/// which nearest sampling picks for most of a bolt's fragments (#3171): the
|
||||
/// point sampled is at least a texel inside the sprite.
|
||||
#[test]
|
||||
fn white_uv_is_inside_the_white_sprite() {
|
||||
let [u, v] = white_uv(&white_sprite());
|
||||
let (x, y) = (u * ATLAS.0 as f32, v * ATLAS.1 as f32);
|
||||
assert!((41.0..=43.0).contains(&x), "u at texel {x}");
|
||||
assert!((13.0..=15.0).contains(&y), "v at texel {y}");
|
||||
}
|
||||
|
||||
/// A bolt seen from 14 blocks through the atlas as the game binds it (nearest
|
||||
/// sampling, only the white sprite opaque) draws the same pixels as through a
|
||||
/// texture that is white everywhere (#3171: the sprite's corner left a few
|
||||
/// scattered pixels). Run explicitly, like the test above.
|
||||
#[test]
|
||||
#[ignore = "requires GPU; run separately with --ignored --exact"]
|
||||
fn bolt_through_the_atlas_white_sprite_is_fully_drawn() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()),
|
||||
});
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
let offsets = wgpu::vertex_attr_array![7=>Float32x3];
|
||||
let buffers = [
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
}),
|
||||
Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &offsets,
|
||||
}),
|
||||
];
|
||||
// The lightning pipeline (gfx.rs): additive, into RGBA8.
|
||||
let additive = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::SrcAlpha,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Add,
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_lightning"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &buffers,
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: None,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_celestial"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: Some(wgpu::BlendState {
|
||||
color: additive,
|
||||
alpha: additive,
|
||||
}),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
// The game's camera: 70 degrees, reversed depth, the bolt 14 blocks ahead.
|
||||
let globals = crate::gfx::Globals {
|
||||
view_proj: (glam::Mat4::perspective_rh(70f32.to_radians(), 1.0, 400.0, 0.05)
|
||||
* glam::Mat4::look_at_rh(glam::Vec3::ZERO, glam::Vec3::Z, glam::Vec3::Y))
|
||||
.to_cols_array_2d(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let globals = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::bytes_of(&globals),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let texture = |(width, height): (u32, u32), data: &[u8]| {
|
||||
device
|
||||
.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
data,
|
||||
)
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let white = texture((1, 1), &[255; 4]);
|
||||
// Transparent but for the white sprite.
|
||||
let mut texels = vec![0u8; (ATLAS.0 * ATLAS.1 * 4) as usize];
|
||||
for y in 12..16 {
|
||||
for x in 40..44 {
|
||||
let i = ((y * ATLAS.0 + x) * 4) as usize;
|
||||
texels[i..i + 4].copy_from_slice(&[255; 4]);
|
||||
}
|
||||
}
|
||||
let atlas = texture(ATLAS, &texels);
|
||||
// The atlas sampler (gfx.rs): wgpu's default, nearest.
|
||||
let sampler = device.create_sampler(&Default::default());
|
||||
let indices = crate::gfx::Renderer::quad_indices(&device, QUADS);
|
||||
let size = 256;
|
||||
let extent = wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let draw = |view: &wgpu::TextureView, uv: [f32; 2]| -> Vec<u8> {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: globals.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: wgpu::BindingResource::TextureView(&white),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
let bolts = [Bolt {
|
||||
seed: 123,
|
||||
at: [0.0, -1.62, 14.0],
|
||||
}];
|
||||
let input = Input { bolts: &bolts, uv };
|
||||
let mut renderer = Renderer::new();
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
renderer.prepare(&device, &mut uploads, &mut encoder, &input, DVec3::ZERO);
|
||||
let color = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: extent,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
{
|
||||
let target = color.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
renderer.draw(&mut pass, &input);
|
||||
}
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(size * size * 4),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(size * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
extent,
|
||||
);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
read.slice(..).get_mapped_range().unwrap().to_vec()
|
||||
};
|
||||
let lit = |pixels: &[u8]| pixels.chunks(4).filter(|p| p[3] != 0).count();
|
||||
let expected = draw(&white, [0.5; 2]);
|
||||
let drawn = draw(&atlas, white_uv(&white_sprite()));
|
||||
assert!(lit(&expected) > 1000, "{} pixels", lit(&expected));
|
||||
assert_eq!(
|
||||
lit(&drawn),
|
||||
lit(&expected),
|
||||
"pixels drawn through the atlas"
|
||||
);
|
||||
assert!(drawn == expected, "the bolt's colours through the atlas");
|
||||
}
|
||||
@@ -476,10 +476,12 @@ fn sampler(
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `create` and returns its validation error, if any.
|
||||
/// Runs `create` and returns its validation error, if any. A panic while it runs is an error
|
||||
/// too: naga's backends turn a module they cannot write into a panic as the driver gets the
|
||||
/// pipeline (#3525), which a pack's program must not turn into a crash.
|
||||
fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let value = create();
|
||||
let value = crate::crash::contain(create);
|
||||
match pollster::block_on(scope.pop()) {
|
||||
Some(e) => {
|
||||
// The details are in the error's description or its sources.
|
||||
@@ -494,7 +496,7 @@ fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, St
|
||||
}
|
||||
Err(message)
|
||||
}
|
||||
None => Ok(value),
|
||||
None => value.map_err(|panic| format!("the shader compiler failed: {panic}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2629,6 +2631,38 @@ mod tests {
|
||||
assert_eq!(used, [1, 2, 3]);
|
||||
}
|
||||
|
||||
/// #3525: naga's SPIR-V writer panicked on Photon's gbuffers programs as the pipeline was
|
||||
/// made. Such a panic skips the pass, as a validation error does, and the device goes on.
|
||||
#[test]
|
||||
fn a_shader_compiler_panic_skips_the_pass() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let failed = checked(&device, || -> wgpu::Buffer {
|
||||
panic!("internal error: entered unreachable code: Expression [89] is not cached!")
|
||||
});
|
||||
let error = failed.expect_err("the panic comes back as an error");
|
||||
assert!(error.contains("Expression [89] is not cached!"), "{error}");
|
||||
let buffer = |usage| {
|
||||
checked(&device, || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 16,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
};
|
||||
// The device still makes things, and its errors still come back.
|
||||
assert!(buffer(wgpu::BufferUsages::UNIFORM).is_ok());
|
||||
assert!(buffer(wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::MAP_WRITE).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_formats_map_to_renderable_ones() {
|
||||
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
|
||||
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
|
||||
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
|
||||
//! then drawn onto the swapchain image as a fullscreen triangle.
|
||||
|
||||
/// The texture a frame to read back is drawn into, and the pass that puts it
|
||||
/// on the swapchain afterwards.
|
||||
#[derive(Default)]
|
||||
pub struct Readback {
|
||||
texture: Option<wgpu::Texture>,
|
||||
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
|
||||
/// A frame drew into `texture` and still has to reach the swapchain.
|
||||
drawn: bool,
|
||||
}
|
||||
|
||||
impl Readback {
|
||||
/// The texture this frame draws into instead of the swapchain image
|
||||
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
|
||||
/// image cannot be copied from.
|
||||
pub fn target(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
surface: &wgpu::Texture,
|
||||
screenshot: bool,
|
||||
) -> Option<wgpu::Texture> {
|
||||
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
|
||||
return None;
|
||||
}
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self
|
||||
.texture
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size() != surface.size() || t.format() != format)
|
||||
{
|
||||
self.texture = Some(device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Horizon screenshot frame"),
|
||||
size: surface.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
}));
|
||||
}
|
||||
self.drawn = true;
|
||||
self.texture.clone()
|
||||
}
|
||||
|
||||
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
|
||||
/// when this frame drew straight into it.
|
||||
pub fn present(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
if !std::mem::take(&mut self.drawn) {
|
||||
return;
|
||||
}
|
||||
let Some(texture) = &self.texture else {
|
||||
return;
|
||||
};
|
||||
// The frame's passes write the swapchain through its non-sRGB view
|
||||
// (`view_formats`), so the copy goes there unconverted too.
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
|
||||
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
|
||||
}
|
||||
let target = surface.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
let source = texture.create_view(&Default::default());
|
||||
if let Some((_, blitter)) = &self.blitter {
|
||||
blitter.copy(device, encoder, &source, &target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
fn texture(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 70,
|
||||
height: 3,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Bgra8Unorm,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn clear(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: wgpu::Color) {
|
||||
let view = texture.create_view(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(color),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
fn read(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
buffer.get_mapped_range(..).unwrap().to_vec()
|
||||
}
|
||||
|
||||
/// The last pixel of the last row of a 70x3 frame read back by `copy_frame`.
|
||||
fn last_pixel(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
|
||||
let pixels = read(device, buffer);
|
||||
let row = 70usize * 4;
|
||||
let last = row.next_multiple_of(256) * 2 + row - 4;
|
||||
pixels[last..last + 4].to_vec()
|
||||
}
|
||||
|
||||
/// The renderer's screenshot path on a surface like OpenGL's (#3510): the
|
||||
/// frame is drawn into `target`, read back with `copy_frame` and drawn on
|
||||
/// the swapchain, with no validation error (the copy used to need the
|
||||
/// swapchain's missing `COPY_SRC`), and the swapchain shows the frame.
|
||||
#[test]
|
||||
fn screenshot_of_a_surface_without_copy_src() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
// Sampled only so the test can look at what reached it.
|
||||
let surface = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
);
|
||||
let check = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
|
||||
let mut readback = Readback::default();
|
||||
let colors = [
|
||||
(1.0, [0, 0, 255, 255]),
|
||||
// A second screenshot reuses the texture.
|
||||
(0.0, [255, 0, 0, 255]),
|
||||
];
|
||||
for (r, bgra) in colors {
|
||||
let color = wgpu::Color {
|
||||
r,
|
||||
g: 0.0,
|
||||
b: 1.0 - r,
|
||||
a: 1.0,
|
||||
};
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let frame = readback
|
||||
.target(&device, &surface, true)
|
||||
.unwrap_or_else(|| surface.clone());
|
||||
clear(&mut encoder, &frame, color);
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
|
||||
readback.present(&device, &mut encoder, &surface);
|
||||
queue.submit([encoder.finish()]);
|
||||
if let Some(error) = pollster::block_on(scope.pop()) {
|
||||
panic!("{error}");
|
||||
}
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
|
||||
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
|
||||
queue.submit([encoder.finish()]);
|
||||
assert_eq!(last_pixel(&device, &buffer), bgra);
|
||||
}
|
||||
assert!(!readback.drawn);
|
||||
}
|
||||
|
||||
/// A surface that can be copied from, or a frame nobody reads back,
|
||||
/// draws straight into the swapchain image.
|
||||
#[test]
|
||||
fn frames_draw_straight_to_a_readable_surface() {
|
||||
let Some((device, _)) = device() else {
|
||||
return;
|
||||
};
|
||||
let mut readback = Readback::default();
|
||||
let readable = texture(
|
||||
&device,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
assert!(readback.target(&device, &readable, true).is_none());
|
||||
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
assert!(readback.target(&device, &surface, false).is_none());
|
||||
assert!(readback.texture.is_none() && !readback.drawn);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,15 @@ struct TaaUniform {
|
||||
jitter: [f32; 4],
|
||||
}
|
||||
|
||||
/// TAA's GPU resources, absent when the device cannot compile the shader
|
||||
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
|
||||
/// from depth textures).
|
||||
struct TaaResources {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
uniform: wgpu::Buffer,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
layout: wgpu::BindGroupLayout,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -57,9 +66,7 @@ pub struct Post {
|
||||
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
|
||||
taa_on: bool,
|
||||
target: Option<WorldTarget>,
|
||||
taa_layout: wgpu::BindGroupLayout,
|
||||
taa_pipeline: wgpu::RenderPipeline,
|
||||
taa_uniform: wgpu::Buffer,
|
||||
taa_resources: Option<TaaResources>,
|
||||
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
|
||||
taa_frame: u32,
|
||||
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
|
||||
@@ -105,40 +112,11 @@ impl Post {
|
||||
count: None,
|
||||
};
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let taa_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
@@ -166,6 +144,88 @@ impl Post {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
// The OpenGL backend's GLSL translator does not support textureLoad
|
||||
// from depth textures: on devices running through OpenGL (such as older
|
||||
// Intel GPUs on Linux), TAA cannot compile its pipeline.
|
||||
let taa_resources = {
|
||||
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon TAA bindings"),
|
||||
entries: &[
|
||||
texture(0, float),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
count: None,
|
||||
},
|
||||
texture(2, float),
|
||||
texture(3, wgpu::TextureSampleType::Depth),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 4,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
let taa_pipeline_layout =
|
||||
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
bind_group_layouts: &[Some(&taa_layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
|
||||
let taa_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: Some(&taa_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_post"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_taa"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: HISTORY_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let internal = pollster::block_on(internal.pop());
|
||||
match internal.or(pollster::block_on(validation.pop())) {
|
||||
None => {
|
||||
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
Some(TaaResources {
|
||||
layout: taa_layout,
|
||||
pipeline: taa_pipeline,
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
Some(e) => {
|
||||
log::warn!("Horizon renderer: temporal anti-aliasing unavailable ({e})");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Horizon post"),
|
||||
mag_filter: wgpu::FilterMode::Linear,
|
||||
@@ -175,15 +235,7 @@ impl Post {
|
||||
Self {
|
||||
copy: pipeline("fs_copy", &pipeline_layout, format),
|
||||
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
|
||||
taa_pipeline: pipeline("fs_taa", &taa_pipeline_layout, HISTORY_FORMAT),
|
||||
taa_uniform: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
size: std::mem::size_of::<TaaUniform>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
layout,
|
||||
taa_layout,
|
||||
sampler,
|
||||
format,
|
||||
scale: 1.0,
|
||||
@@ -192,12 +244,18 @@ impl Post {
|
||||
upscale: 1.0,
|
||||
taa_on: false,
|
||||
target: None,
|
||||
taa_resources,
|
||||
taa_frame: 0,
|
||||
taa_previous: None,
|
||||
taa_this_frame: bytemuck::Zeroable::zeroed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether TAA is supported on this GPU/backend.
|
||||
pub fn supports_taa(&self) -> bool {
|
||||
self.taa_resources.is_some()
|
||||
}
|
||||
|
||||
/// The world's rendered size and the size it is shown at (TAA's history).
|
||||
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
|
||||
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
|
||||
@@ -212,7 +270,7 @@ impl Post {
|
||||
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
|
||||
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
|
||||
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
|
||||
self.taa_on = on && self.taa;
|
||||
self.taa_on = on && self.taa && self.taa_resources.is_some();
|
||||
if !self.taa_on {
|
||||
self.taa_previous = None;
|
||||
return Mat4::IDENTITY;
|
||||
@@ -290,7 +348,8 @@ impl Post {
|
||||
)
|
||||
.create_view(&Default::default());
|
||||
let group = self.bind(device, &color);
|
||||
let taa = history_size.map(|output| {
|
||||
let taa = history_size.and_then(|output| {
|
||||
let res = self.taa_resources.as_ref()?;
|
||||
let history = [0, 1].map(|_| {
|
||||
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
|
||||
.create_view(&Default::default())
|
||||
@@ -298,7 +357,7 @@ impl Post {
|
||||
let resolve = [0, 1].map(|i| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon TAA"),
|
||||
layout: &self.taa_layout,
|
||||
layout: &res.layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
@@ -318,18 +377,18 @@ impl Post {
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.taa_uniform.as_entire_binding(),
|
||||
resource: res.uniform.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
})
|
||||
});
|
||||
let show = [0, 1].map(|i| self.bind(device, &history[i]));
|
||||
History {
|
||||
Some(History {
|
||||
size: output,
|
||||
history,
|
||||
resolve,
|
||||
show,
|
||||
}
|
||||
})
|
||||
});
|
||||
// New textures: no history yet.
|
||||
self.taa_this_frame.jitter[2] = 1.0;
|
||||
@@ -381,12 +440,12 @@ impl Post {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let Some(res) = &self.taa_resources else {
|
||||
self.finish_with(encoder, screen, &target.group);
|
||||
return;
|
||||
};
|
||||
let i = (self.taa_frame % 2) as usize;
|
||||
queue.write_buffer(
|
||||
&self.taa_uniform,
|
||||
0,
|
||||
bytemuck::bytes_of(&self.taa_this_frame),
|
||||
);
|
||||
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &taa.history[i],
|
||||
resolve_target: None,
|
||||
@@ -401,7 +460,7 @@ impl Post {
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.taa_pipeline);
|
||||
pass.set_pipeline(&res.pipeline);
|
||||
pass.set_bind_group(0, &taa.resolve[i], &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
drop(pass);
|
||||
@@ -485,4 +544,21 @@ mod tests {
|
||||
[0.5, 0.25, 0.75]
|
||||
);
|
||||
}
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_post_pipelines_compile_or_fall_back() {
|
||||
let Some((device, _)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
|
||||
// Copy and FXAA pipelines always compile; TAA compiles if supported by backend.
|
||||
let _ = post.supports_taa();
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,39 @@
|
||||
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
|
||||
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
|
||||
//! player makes here depart from it.
|
||||
use std::{fs, path::Path};
|
||||
use std::{
|
||||
fs,
|
||||
path::Path,
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
pub const FILE: &str = "horizon-graphics.txt";
|
||||
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
|
||||
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
|
||||
pub const HDR_MARKER: &str = "horizon-hdr-pending";
|
||||
|
||||
/// What the system reports for the display HDR output goes to, in nits in HDR output's
|
||||
/// scale: where its SDR white sits (the desktop's white) and the peak.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Reported {
|
||||
pub paper_white: u32,
|
||||
pub peak: u32,
|
||||
}
|
||||
|
||||
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
|
||||
|
||||
/// Sets what the system reports (the renderer, when it opens its surface).
|
||||
pub fn report(reported: Option<Reported>) {
|
||||
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
|
||||
REPORTED[0].store(paper_white, Ordering::Relaxed);
|
||||
REPORTED[1].store(peak, Ordering::Relaxed);
|
||||
}
|
||||
/// What the system reports, if it does.
|
||||
pub fn reported() -> Option<Reported> {
|
||||
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
|
||||
(peak > 0).then_some(Reported { paper_white, peak })
|
||||
}
|
||||
|
||||
/// Simple-level presets. `Custom` leaves every option as the player set it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum Preset {
|
||||
@@ -285,6 +311,16 @@ pub fn reduced_far_detail_suggested() -> bool {
|
||||
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
|
||||
/// opens its surface; the settings open HDR's screen only then.
|
||||
pub fn offer_hdr(on: bool) {
|
||||
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn hdr_offered() -> bool {
|
||||
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -343,10 +379,18 @@ pub struct Graphics {
|
||||
pub upscaling: Upscaling,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits.
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
|
||||
pub hdr_paper_white: u32,
|
||||
/// HDR peak brightness the world's highlights reach, in nits.
|
||||
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
|
||||
/// what the system reports for the display ([`Graphics::peak_nits`]).
|
||||
pub hdr_peak: u32,
|
||||
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
|
||||
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
|
||||
pub hdr_black_level: u32,
|
||||
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
|
||||
/// first-person hand stays at the world's.
|
||||
pub hdr_ui_brightness: u32,
|
||||
/// Battery mode: at most 60 frames per second in a world and 30 in menus
|
||||
/// or behind other windows, and half the worker threads. Off by default;
|
||||
/// it changes when frames are drawn, never what they show or the ticks.
|
||||
@@ -381,8 +425,10 @@ impl Default for Graphics {
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
hdr: false,
|
||||
hdr_paper_white: 200,
|
||||
hdr_peak: 1000,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
hdr_black_level: 0,
|
||||
hdr_ui_brightness: 100,
|
||||
battery: false,
|
||||
api: GraphicsApi::Auto,
|
||||
shader_pack: String::new(),
|
||||
@@ -445,8 +491,22 @@ impl Graphics {
|
||||
"taa" => g.taa = flag(g.taa),
|
||||
"upscaling" => g.upscaling = Upscaling::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
|
||||
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
0 => 0,
|
||||
n => n.clamp(80, 480),
|
||||
}
|
||||
}
|
||||
"hdrPeak" => {
|
||||
g.hdr_peak = match number(g.hdr_peak) {
|
||||
0 => 0,
|
||||
n => n.clamp(200, 2000),
|
||||
}
|
||||
}
|
||||
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
|
||||
"hdrUiBrightness" => {
|
||||
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
|
||||
}
|
||||
"batteryMode" => g.battery = flag(g.battery),
|
||||
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
|
||||
"pickedShaderPack" => g.shader_pack = value.to_owned(),
|
||||
@@ -464,7 +524,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -494,6 +554,8 @@ impl Graphics {
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
self.hdr_black_level,
|
||||
self.hdr_ui_brightness,
|
||||
bool_text(self.battery),
|
||||
self.api.name(),
|
||||
self.shader_pack,
|
||||
@@ -502,6 +564,22 @@ impl Graphics {
|
||||
pub fn save(&self, dir: &Path) {
|
||||
let _ = fs::write(dir.join(FILE), self.serialize());
|
||||
}
|
||||
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
|
||||
/// system reports it, else 203 (BT.2408's reference white).
|
||||
pub fn paper_white_nits(&self) -> u32 {
|
||||
match self.hdr_paper_white {
|
||||
0 => reported().map_or(203, |r| r.paper_white),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// The peak the world's highlights reach, in nits: the player's, or on Auto
|
||||
/// what the system reports, else 1000.
|
||||
pub fn peak_nits(&self) -> u32 {
|
||||
match self.hdr_peak {
|
||||
0 => reported().map_or(1000, |r| r.peak),
|
||||
n => n,
|
||||
}
|
||||
}
|
||||
/// Worker threads for chunk generation and meshing.
|
||||
pub fn worker_threads(&self) -> usize {
|
||||
if self.workers > 0 {
|
||||
@@ -611,12 +689,19 @@ mod tests {
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
hdr_black_level: 12,
|
||||
hdr_ui_brightness: 60,
|
||||
battery: true,
|
||||
api: GraphicsApi::Dx12,
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
|
||||
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
|
||||
// The old menus' Simple / Advanced choice is ignored, and so is the
|
||||
// shader pack older versions saved.
|
||||
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
|
||||
|
||||
@@ -130,8 +130,9 @@ impl Style {
|
||||
label: &Text,
|
||||
value: f32,
|
||||
hover: bool,
|
||||
active: bool,
|
||||
) {
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
|
||||
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
|
||||
}
|
||||
/// The cost tag and the description beside a control, wrapped to two lines.
|
||||
pub fn description(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Horizon's graphics settings screens, Simple and Advanced: which settings
|
||||
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
@@ -14,6 +14,8 @@ use crate::{
|
||||
pub enum Level {
|
||||
Simple,
|
||||
Advanced,
|
||||
/// HDR output's own settings, opened from Advanced while HDR is on.
|
||||
Hdr,
|
||||
}
|
||||
|
||||
/// Horizon's own settings (`graphics::Graphics`).
|
||||
@@ -38,8 +40,12 @@ pub enum H {
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
Hdr,
|
||||
HdrPaperWhite,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrBlackLevel,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
BatteryMode,
|
||||
GraphicsApi,
|
||||
}
|
||||
@@ -120,6 +126,34 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
);
|
||||
let mut rows = match level {
|
||||
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
|
||||
Level::Hdr => vec![
|
||||
Row::Header("HDR"),
|
||||
h(
|
||||
H::Hdr,
|
||||
None,
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
|
||||
),
|
||||
h(
|
||||
H::HdrBlackLevel,
|
||||
None,
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"How bright highlights get. Auto: the peak your system reports.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"How bright white paper and menus look. Auto: your desktop's white.",
|
||||
),
|
||||
h(
|
||||
H::HdrUiBrightness,
|
||||
None,
|
||||
"How bright the HUD and menus are, against paper white.",
|
||||
),
|
||||
],
|
||||
Level::Advanced => vec![
|
||||
Row::Header("Rendering"),
|
||||
render,
|
||||
@@ -230,20 +264,10 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
|
||||
),
|
||||
h(
|
||||
H::Hdr,
|
||||
H::HdrSettings,
|
||||
None,
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"HDR: how bright white paper and menus look. Match your desktop.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"HDR: how bright the brightest highlights get. Your display's peak.",
|
||||
),
|
||||
h(
|
||||
H::PresentMode,
|
||||
None,
|
||||
@@ -351,8 +375,18 @@ impl H {
|
||||
}
|
||||
H::Workers => Some(g.workers as f32 / max_workers() as f32),
|
||||
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
|
||||
H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0),
|
||||
H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.0),
|
||||
// Auto, then 80..480 by 10.
|
||||
H::HdrPaperWhite => Some(match g.hdr_paper_white {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 70.0) / 410.0,
|
||||
}),
|
||||
// Auto, then 200..2000 by 50.
|
||||
H::HdrPeak => Some(match g.hdr_peak {
|
||||
0 => 0.0,
|
||||
n => (n as f32 - 150.0) / 1850.0,
|
||||
}),
|
||||
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
|
||||
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -403,8 +437,23 @@ impl H {
|
||||
),
|
||||
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
|
||||
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
|
||||
H::HdrSettings => Text::literal("HDR Settings..."),
|
||||
H::HdrPaperWhite => match g.hdr_paper_white {
|
||||
0 => labelled(
|
||||
"Paper White",
|
||||
format!("Auto ({} nits)", g.paper_white_nits()),
|
||||
),
|
||||
n => labelled("Paper White", format!("{n} nits")),
|
||||
},
|
||||
H::HdrPeak => match g.hdr_peak {
|
||||
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
|
||||
n => labelled("Peak Brightness", format!("{n} nits")),
|
||||
},
|
||||
H::HdrBlackLevel => labelled(
|
||||
"Black Level",
|
||||
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::GraphicsApi => labelled("Graphics API", g.api.label()),
|
||||
H::Overlay => labelled(
|
||||
@@ -427,8 +476,20 @@ impl H {
|
||||
}
|
||||
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
|
||||
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
|
||||
H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32,
|
||||
H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32,
|
||||
H::HdrPaperWhite => {
|
||||
g.hdr_paper_white = match (v * 41.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 70 + step * 10,
|
||||
}
|
||||
}
|
||||
H::HdrPeak => {
|
||||
g.hdr_peak = match (v * 37.0).round() as u32 {
|
||||
0 => 0,
|
||||
step => 150 + step * 50,
|
||||
}
|
||||
}
|
||||
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
|
||||
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -504,6 +565,25 @@ impl Item {
|
||||
fn available(self) -> bool {
|
||||
!matches!(self, Item::Vanilla(o) if !o.available())
|
||||
}
|
||||
/// Why a control can't be used now, when it can't: HDR's screen opens only where
|
||||
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
|
||||
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
}
|
||||
match self {
|
||||
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
}
|
||||
Item::Horizon(
|
||||
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
|
||||
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// On/off settings and their value.
|
||||
pub fn switch(self, s: &Settings) -> Option<bool> {
|
||||
let g = &s.horizon;
|
||||
@@ -605,6 +685,7 @@ pub enum Action {
|
||||
SliderDrag,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -624,15 +705,20 @@ struct Widget {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GraphicsUi {
|
||||
scroll: f32,
|
||||
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
|
||||
scroll: ui::Scroll,
|
||||
dragging: Option<Item>,
|
||||
}
|
||||
|
||||
impl GraphicsUi {
|
||||
pub fn reset(&mut self) {
|
||||
self.scroll = 0.0;
|
||||
self.scroll = ui::Scroll::default();
|
||||
self.dragging = None;
|
||||
}
|
||||
/// The scrollbar's column: right of the descriptions.
|
||||
fn scrollbar_x(w: f32) -> f32 {
|
||||
STYLE.row_x(w) + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0
|
||||
}
|
||||
fn bounds(h: f32) -> (f32, f32) {
|
||||
(STYLE.band, h - STYLE.band)
|
||||
}
|
||||
@@ -657,7 +743,7 @@ impl GraphicsUi {
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, _) = Self::bounds(h);
|
||||
let x = STYLE.row_x(w);
|
||||
let mut y = top + 4.0 - self.scroll;
|
||||
let mut y = top + 4.0 - self.scroll.amount;
|
||||
let mut widgets = Vec::new();
|
||||
let mut headers = Vec::new();
|
||||
for row in extra_rows(level, s) {
|
||||
@@ -683,6 +769,17 @@ impl GraphicsUi {
|
||||
let other = match level {
|
||||
Level::Simple => Action::OpenAdvanced,
|
||||
Level::Advanced => Action::OpenSimple,
|
||||
// One Done, back to Advanced.
|
||||
Level::Hdr => {
|
||||
widgets.push(Widget {
|
||||
x: cx - 100.0,
|
||||
y: by,
|
||||
w: 200.0,
|
||||
target: Target::Button(Action::Done),
|
||||
list: false,
|
||||
});
|
||||
return (widgets, headers, content);
|
||||
}
|
||||
};
|
||||
widgets.push(Widget {
|
||||
x: cx - 155.0,
|
||||
@@ -702,7 +799,7 @@ impl GraphicsUi {
|
||||
}
|
||||
fn clamp_scroll(&mut self, h: f32, content: f32) {
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll = self.scroll.clamp(0.0, (content - (bottom - top)).max(0.0));
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
}
|
||||
pub fn draw(
|
||||
&mut self,
|
||||
@@ -744,12 +841,10 @@ impl GraphicsUi {
|
||||
continue;
|
||||
}
|
||||
let label = item.label(s);
|
||||
let live = item.available();
|
||||
if hover && !live {
|
||||
tooltip = Some(atlas.font.split(
|
||||
&Text::literal("This feature is not yet available in Horizon."),
|
||||
170.0,
|
||||
));
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
if hover && let Some(why) = blocked {
|
||||
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
|
||||
}
|
||||
match item.slider(s) {
|
||||
Some(value) => STYLE.slider(
|
||||
@@ -759,6 +854,7 @@ impl GraphicsUi {
|
||||
&label,
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
@@ -799,15 +895,16 @@ impl GraphicsUi {
|
||||
ui::scrollbar(
|
||||
batch,
|
||||
atlas,
|
||||
x + STYLE.control_width + STYLE.gap + STYLE.text_width(w) + 6.0,
|
||||
Self::scrollbar_x(w),
|
||||
top,
|
||||
bottom,
|
||||
content,
|
||||
self.scroll,
|
||||
self.scroll.amount,
|
||||
);
|
||||
let title = match level {
|
||||
Level::Simple => "Graphics",
|
||||
Level::Advanced => "Graphics: Advanced",
|
||||
Level::Hdr => "Graphics: HDR",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
if let Some(lines) = tooltip {
|
||||
@@ -825,9 +922,14 @@ impl GraphicsUi {
|
||||
drag: bool,
|
||||
right: bool,
|
||||
) -> Vec<Action> {
|
||||
let (widgets, _, _) = self.layout(level, s, w, h);
|
||||
let (widgets, _, content) = self.layout(level, s, w, h);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
if drag {
|
||||
// AbstractScrollArea.mouseDragged: the held scrollbar follows the pointer.
|
||||
if self.scroll.held() {
|
||||
self.scroll.drag(y, top, bottom, content);
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(item) = self.dragging
|
||||
&& let Some(wd) = widgets
|
||||
.iter()
|
||||
@@ -841,6 +943,13 @@ impl GraphicsUi {
|
||||
if right {
|
||||
return Vec::new();
|
||||
}
|
||||
// AbstractScrollArea.updateScrolling: a press on the scrollbar holds it, silently.
|
||||
if self
|
||||
.scroll
|
||||
.press((x, y), Self::scrollbar_x(w), top, bottom, content)
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
for wd in widgets {
|
||||
if !ui::hovered((x, y), wd.x, wd.y, wd.w, 20.0) {
|
||||
continue;
|
||||
@@ -851,7 +960,10 @@ impl GraphicsUi {
|
||||
return match wd.target {
|
||||
Target::Button(action) => vec![Action::ButtonClick, action],
|
||||
// A disabled control does nothing: no sound, no preset change.
|
||||
Target::Item(item) if !item.available() => Vec::new(),
|
||||
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
|
||||
Target::Item(Item::Horizon(H::HdrSettings)) => {
|
||||
vec![Action::ButtonClick, Action::OpenHdr]
|
||||
}
|
||||
Target::Item(item) => {
|
||||
if item.slider(s).is_some() {
|
||||
self.dragging = Some(item);
|
||||
@@ -866,14 +978,16 @@ impl GraphicsUi {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
/// Lets go of a slider (true: it plays its click) or the scrollbar.
|
||||
pub fn release(&mut self) -> bool {
|
||||
self.scroll.release();
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
pub fn scroll(&mut self, level: Level, s: &Settings, h: f32, amount: f32) {
|
||||
let content = Self::content_height(level, s);
|
||||
self.clamp_scroll(h, content);
|
||||
self.scroll -= amount * 18.0;
|
||||
self.clamp_scroll(h, content);
|
||||
let (top, bottom) = Self::bounds(h);
|
||||
self.scroll.clamp(top, bottom, content);
|
||||
self.scroll.wheel(amount, 18.0, top, bottom, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -881,6 +995,53 @@ impl GraphicsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hdr_settings_open_where_hdr_is_offered() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 2000.0);
|
||||
let at = |ui: &mut GraphicsUi, s: &Settings, level, item| {
|
||||
let (widgets, _, _) = ui.layout(level, s, size.0, size.1);
|
||||
let w = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
(w.x + 1.0, w.y + 1.0)
|
||||
};
|
||||
let button = at(&mut ui, &s, Level::Advanced, Item::Horizon(H::HdrSettings));
|
||||
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
|
||||
ui.click(level, s, point, size, [480, 2000], false, false)
|
||||
};
|
||||
crate::graphics::offer_hdr(false);
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
// Not even with HDR saved on, from another computer.
|
||||
s.horizon.hdr = true;
|
||||
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
|
||||
s.horizon.hdr = false;
|
||||
crate::graphics::offer_hdr(true);
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Advanced, button),
|
||||
[Action::ButtonClick, Action::OpenHdr]
|
||||
);
|
||||
// On HDR's screen, the sliders wait for the switch above them.
|
||||
let peak = at(&mut ui, &s, Level::Hdr, Item::Horizon(H::HdrPeak));
|
||||
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
|
||||
s.horizon.hdr = true;
|
||||
assert_eq!(
|
||||
click(&mut ui, &mut s, Level::Hdr, peak),
|
||||
[Action::SliderDrag]
|
||||
);
|
||||
let on = |level, item| {
|
||||
extra_rows(level, &s)
|
||||
.iter()
|
||||
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
|
||||
};
|
||||
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
|
||||
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
|
||||
for item in [H::HdrBlackLevel, H::HdrPaperWhite, H::HdrUiBrightness] {
|
||||
assert!(on(Level::Hdr, item) && !on(Level::Advanced, item));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
|
||||
let mut s = Settings::default();
|
||||
@@ -960,29 +1121,68 @@ mod tests {
|
||||
assert!(max > 4.5);
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
ui.scroll(level, &s, h, -1000.0);
|
||||
assert_eq!(ui.scroll, max);
|
||||
assert_eq!(ui.scroll.amount, max);
|
||||
}
|
||||
ui.scroll(level, &s, h, 0.25);
|
||||
assert_eq!(ui.scroll, max - 4.5);
|
||||
assert_eq!(ui.scroll.amount, max - 4.5);
|
||||
ui.scroll(level, &s, h, 1000.0);
|
||||
ui.scroll(level, &s, h, -0.25);
|
||||
assert_eq!(ui.scroll, 4.5);
|
||||
assert_eq!(ui.scroll.amount, 4.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrollbar_can_be_held_and_dragged() {
|
||||
// #3552: AbstractScrollArea.updateScrolling holds the scrollbar on a press over its
|
||||
// column, mouseDragged moves the list with the pointer, onRelease lets go.
|
||||
let mut s = Settings::default();
|
||||
let size = (480.0, 2.0 * STYLE.band + 60.0);
|
||||
let (top, bottom) = GraphicsUi::bounds(size.1);
|
||||
// On the scrollbar column, as `draw` paints it.
|
||||
let x =
|
||||
STYLE.row_x(size.0) + STYLE.control_width + STYLE.gap + STYLE.text_width(size.0) + 8.0;
|
||||
let first_row = |ui: &mut GraphicsUi| {
|
||||
ui.layout(Level::Advanced, &Settings::default(), size.0, size.1)
|
||||
.0[0]
|
||||
.y
|
||||
};
|
||||
let mut ui = GraphicsUi::default();
|
||||
let start = first_row(&mut ui);
|
||||
let at = |y| (x, y);
|
||||
let press = |ui: &mut GraphicsUi, s: &mut Settings, y, drag| {
|
||||
ui.click(Level::Advanced, s, at(y), size, [854, 480], drag, false)
|
||||
};
|
||||
// The press itself makes no sound and changes nothing.
|
||||
assert!(press(&mut ui, &mut s, top + 5.0, false).is_empty());
|
||||
assert!(press(&mut ui, &mut s, top + 15.0, true).is_empty());
|
||||
let moved = first_row(&mut ui);
|
||||
assert!(moved < start, "dragging down scrolls the list down");
|
||||
// Past the bottom of the list: the last row.
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
let content = GraphicsUi::content_height(Level::Advanced, &s);
|
||||
assert_eq!(first_row(&mut ui), start - (content - (bottom - top)));
|
||||
// Above the list: the first row again.
|
||||
press(&mut ui, &mut s, top - 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
// Released: drags no longer scroll, and no slider click plays.
|
||||
assert!(!ui.release());
|
||||
press(&mut ui, &mut s, bottom + 50.0, true);
|
||||
assert_eq!(first_row(&mut ui), start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_clamps_before_positioning_graphics_rows() {
|
||||
let s = Settings::default();
|
||||
for level in [Level::Simple, Level::Advanced] {
|
||||
let mut ui = GraphicsUi::default();
|
||||
ui.scroll(level, &s, 2.0 * STYLE.band + 10.0, -1000.0);
|
||||
assert!(ui.scroll > 0.0);
|
||||
assert!(ui.scroll.amount > 0.0);
|
||||
let (widgets, headers, _) = ui.layout(level, &s, 480.0, 4000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
if let Some((y, _)) = headers.first() {
|
||||
assert_eq!(*y, STYLE.band + 4.0);
|
||||
}
|
||||
@@ -992,7 +1192,7 @@ mod tests {
|
||||
expected.iter().map(|w| w.y).collect::<Vec<_>>()
|
||||
);
|
||||
ui.scroll(level, &s, 4000.0, -1000.0);
|
||||
assert_eq!(ui.scroll, 0.0);
|
||||
assert_eq!(ui.scroll.amount, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ struct Params {
|
||||
debug: f32,
|
||||
// 1 for an HDR10 swapchain, 0 for scRGB.
|
||||
pq: f32,
|
||||
// The display's black level in nits; 0 leaves the picture as it is.
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
|
||||
@@ -56,20 +60,51 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375));
|
||||
}
|
||||
|
||||
// ST 2084's inverse: the PQ signal to nits.
|
||||
fn pq_nits(e: vec3<f32>) -> vec3<f32> {
|
||||
let p = pow(clamp(e, vec3(0.0), vec3(1.0)), vec3(1.0 / 78.84375));
|
||||
let y = max(p - 0.8359375, vec3(0.0)) / (18.8515625 - 18.6875 * p);
|
||||
return 10000.0 * pow(y, vec3(1.0 / 0.1593017578125));
|
||||
}
|
||||
|
||||
// ITU-R BT.2390's black level lift (its EETF without the knee) on the PQ signal
|
||||
// `e`: black moves up to the display's black level, the peak stays, and the lift
|
||||
// fades out by the fourth power on the way up, so only the shadows move. A display
|
||||
// that can't go darker than its black level would crush everything below it.
|
||||
fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
let top = pq(vec3(params.peak * params.paper * 80.0)).x;
|
||||
let n = min(e / top, vec3(1.0));
|
||||
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
|
||||
}
|
||||
|
||||
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Hdr::keep_world: once the world is kept, the frame's alpha starts at 0 again.
|
||||
@fragment fn fs_clear_alpha() -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let c = textureLoad(frame, at, 0).rgb;
|
||||
let f = textureLoad(frame, at, 0);
|
||||
let c = f.rgb;
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(max(c, vec3(0.0)), vec3(2.2));
|
||||
let w = textureLoad(world, at, 0);
|
||||
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
|
||||
// (terrain.wgsl, the celestial pipeline): only those shine, and only where
|
||||
// nothing was drawn over them since: grey text over the white sun still looks
|
||||
// like the sun darkened, but it raised the frame's alpha.
|
||||
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
|
||||
&& shows_world(c, w.rgb) {
|
||||
// Brightness of the world pixel, before the vignette darkens the frame
|
||||
// toward its edges: a light source shines the same anywhere on screen.
|
||||
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
|
||||
@@ -84,9 +119,15 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0);
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
|
||||
}
|
||||
if params.black > 0.0 {
|
||||
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ pub mod skins;
|
||||
pub mod sound_engine;
|
||||
pub mod sound_events;
|
||||
pub mod spans;
|
||||
pub mod split_layout;
|
||||
mod sprite_cache;
|
||||
pub mod sprites;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -435,6 +435,28 @@ impl Plan {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Whether chunk column (x, z) is drawn by the tile planned there (ready;
|
||||
/// at level 0, also `filled`) rather than by a coarser ancestor standing
|
||||
/// in for it, or is past the far terrain.
|
||||
fn replaces(
|
||||
&self,
|
||||
(x, z): (i32, i32),
|
||||
ready: &FxHashMap<TileKey, impl Sized>,
|
||||
filled: impl Fn(TileKey) -> bool,
|
||||
) -> bool {
|
||||
let Some(key) = (0..=MAX_LEVEL)
|
||||
.map(|level| TileKey {
|
||||
x: (x * 16).div_euclid(16 << level),
|
||||
z: (z * 16).div_euclid(16 << level),
|
||||
level,
|
||||
})
|
||||
.find(|key| self.nodes.contains(key))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
// A split node's child there is out of range: nothing is drawn.
|
||||
self.split.contains(&key) || (ready.contains_key(&key) && (key.level > 0 || filled(key)))
|
||||
}
|
||||
}
|
||||
/// Visited chunks' summaries (`Lod::saved`) and their reductions.
|
||||
type Saved = FxHashMap<(i32, i32), Arc<[Column; 256]>>;
|
||||
@@ -573,8 +595,17 @@ pub struct Lod {
|
||||
active: FxHashSet<TileKey>,
|
||||
dirty: FxHashSet<TileKey>,
|
||||
removed: Vec<TileKey>,
|
||||
/// The columns left out: `full` and `retiring`.
|
||||
covered: FxHashSet<(i32, i32)>,
|
||||
covered_bounds: Option<[i32; 4]>,
|
||||
/// The full-detail columns `update` was last given.
|
||||
full: FxHashSet<(i32, i32)>,
|
||||
/// Columns that left the full-detail area, still left out (their
|
||||
/// full-detail meshes still drawn) until the tile planned there is ready
|
||||
/// (`Plan::replaces`). Tiles inside that area are never sampled: when the
|
||||
/// render distance shrinks, their coarsest ancestors would otherwise
|
||||
/// stand in for it for minutes.
|
||||
retiring: FxHashSet<(i32, i32)>,
|
||||
/// The covered columns the active meshes leave out, when a mesh job
|
||||
/// has finished since the last `take_cutout`.
|
||||
cutout: Option<FxHashSet<(i32, i32)>>,
|
||||
@@ -777,6 +808,8 @@ impl Lod {
|
||||
removed: Vec::new(),
|
||||
covered: FxHashSet::default(),
|
||||
covered_bounds: None,
|
||||
full: FxHashSet::default(),
|
||||
retiring: FxHashSet::default(),
|
||||
cutout: None,
|
||||
saved: FxHashMap::default(),
|
||||
reduced: FxHashMap::default(),
|
||||
@@ -1038,7 +1071,8 @@ impl Lod {
|
||||
(ox + (index as i32 % 16) * stride).div_euclid(16),
|
||||
(oz + (index as i32 / 16) * stride).div_euclid(16),
|
||||
);
|
||||
if self.covered.contains(&chunk) {
|
||||
// A retiring column's tile is drawn soon: it needs its detail.
|
||||
if self.full.contains(&chunk) {
|
||||
self.detail_masks.entry(key).or_default()[index / 64] |= 1 << (index % 64);
|
||||
} else {
|
||||
chunks.insert(chunk);
|
||||
@@ -1160,31 +1194,13 @@ impl Lod {
|
||||
}
|
||||
/// Takes the new full-detail columns, when they changed (`None`: not).
|
||||
fn cover(&mut self, covered: Option<FxHashSet<(i32, i32)>>) {
|
||||
if let Some(covered) = covered
|
||||
&& self.covered != covered
|
||||
if let Some(full) = covered
|
||||
&& self.full != full
|
||||
{
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
self.covered = covered;
|
||||
self.replanned = true;
|
||||
self.reselect = true;
|
||||
self.retiring.extend(self.full.difference(&full).copied());
|
||||
self.retiring.retain(|column| !full.contains(column));
|
||||
self.full = full;
|
||||
self.leave_out();
|
||||
}
|
||||
// Complete resident columns need no duplicate noise work. Their real
|
||||
// summaries are retained when the full-detail chunks leave the view.
|
||||
@@ -1198,12 +1214,55 @@ impl Lod {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Leaves out the full-detail and retiring columns: the tiles touching
|
||||
/// one that changed are meshed again.
|
||||
fn leave_out(&mut self) {
|
||||
let covered: FxHashSet<_> = self.full.union(&self.retiring).copied().collect();
|
||||
if self.covered == covered {
|
||||
return;
|
||||
}
|
||||
let changes: Vec<_> = self
|
||||
.covered
|
||||
.symmetric_difference(&covered)
|
||||
.copied()
|
||||
.collect();
|
||||
for (x, z) in changes {
|
||||
self.invalidate_chunk(x, z);
|
||||
}
|
||||
self.covered_bounds = covered.iter().fold(None, |bounds, &(x, z)| {
|
||||
Some(match bounds {
|
||||
None => [x * 16, z * 16, (x + 1) * 16, (z + 1) * 16],
|
||||
Some([x0, z0, x1, z1]) => [
|
||||
x0.min(x * 16),
|
||||
z0.min(z * 16),
|
||||
x1.max((x + 1) * 16),
|
||||
z1.max((z + 1) * 16),
|
||||
],
|
||||
})
|
||||
});
|
||||
// Only new covered columns need stand-ins.
|
||||
self.replanned |= !covered.is_subset(&self.covered);
|
||||
self.covered = covered;
|
||||
self.reselect = true;
|
||||
}
|
||||
/// Chooses the tiles to draw and meshes those that changed, when the
|
||||
/// plan, the samples, the covered columns or the active tiles changed.
|
||||
fn select(&mut self) {
|
||||
if !std::mem::take(&mut self.reselect) && self.dirty.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !self.retiring.is_empty() {
|
||||
let (plan, ready, saved, masks) =
|
||||
(&self.plan, &self.ready, &self.saved, &self.detail_masks);
|
||||
// A level-0 tile there is an empty stand-in (`cover`) until the
|
||||
// chunk's summary or generated detail fills it (`apply_fine`).
|
||||
self.retiring.retain(|&column| {
|
||||
!plan.replaces(column, ready, |key| {
|
||||
saved.contains_key(&column) || masks.get(&key) == Some(&[u64::MAX; 4])
|
||||
})
|
||||
});
|
||||
self.leave_out();
|
||||
}
|
||||
{
|
||||
let mut selected = Vec::new();
|
||||
for &key in &self.plan.roots {
|
||||
@@ -1620,6 +1679,48 @@ mod tests {
|
||||
assert_eq!(selected, parent.children());
|
||||
}
|
||||
#[test]
|
||||
fn a_shrunk_render_distance_keeps_columns_until_their_planned_tiles_are_ready() {
|
||||
// Render distance 64 under LOD 64: every tile inside it is a level-0
|
||||
// stand-in for a covered column, and only the roots are sampled.
|
||||
let camera = [8, 8];
|
||||
let before = Plan::new(camera, 64, 64);
|
||||
let after = Plan::with_previous(camera, 8, 64, &before, 0, false);
|
||||
let mut ready: FxHashMap<TileKey, ()> = before
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|key| key.level == 0 || before.roots.contains(key))
|
||||
.map(|&key| (key, ()))
|
||||
.collect();
|
||||
ready.retain(|key, _| after.nodes.contains(key));
|
||||
let contains = |key: &TileKey, (x, z): (i32, i32)| {
|
||||
let [ox, _, oz] = key.origin();
|
||||
(ox..ox + key.size()).contains(&(x * 16)) && (oz..oz + key.size()).contains(&(z * 16))
|
||||
};
|
||||
// 24 chunks out, now past the render distance: its planned tile was
|
||||
// split before, so never sampled, and a root stands in for it.
|
||||
let column = (24, 0);
|
||||
let mut selected = Vec::new();
|
||||
for &root in &after.roots {
|
||||
after.select(root, &ready, &mut selected);
|
||||
}
|
||||
let drawn = selected.iter().filter(|key| contains(key, column));
|
||||
assert_eq!(drawn.map(|key| key.level).min(), Some(6));
|
||||
assert!(!after.replaces(column, &ready, |_| true));
|
||||
let planned = TileKey {
|
||||
x: 12,
|
||||
z: 0,
|
||||
level: 1,
|
||||
};
|
||||
assert!(after.nodes.contains(&planned) && before.split.contains(&planned));
|
||||
ready.insert(planned, ());
|
||||
assert!(after.replaces(column, &ready, |_| true));
|
||||
// 12 chunks out, its level-0 tile is the stand-in until filled.
|
||||
assert!(!after.replaces((12, 0), &ready, |_| false));
|
||||
assert!(after.replaces((12, 0), &ready, |_| true));
|
||||
// Past the far terrain nothing waits.
|
||||
assert!(after.replaces((100, 0), &ready, |_| false));
|
||||
}
|
||||
#[test]
|
||||
fn detail_survives_movement_across_a_split_threshold() {
|
||||
let before = Plan::new([0, 0], 2, 256);
|
||||
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
|
||||
|
||||
@@ -36,11 +36,8 @@ pub struct Installed {
|
||||
pub description: String,
|
||||
/// Why it is not running, when it failed.
|
||||
pub problem: Option<String>,
|
||||
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
|
||||
/// Loaded with `--dev-mod`: always on.
|
||||
pub dev: bool,
|
||||
/// The running mod set a page of widgets (capability `ui`): its row
|
||||
/// gains a Settings button.
|
||||
pub has_settings: bool,
|
||||
/// A native mod (`game/native_hooks.rs`): built into the game, on until
|
||||
/// the player turns it off, and its settings page.
|
||||
pub native: Option<NativePage>,
|
||||
@@ -178,7 +175,6 @@ mod tests {
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
};
|
||||
let mut list = ModList {
|
||||
|
||||
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
|
||||
];
|
||||
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
|
||||
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
|
||||
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
|
||||
/// view distance tops out at 127 anyway.
|
||||
pub const MAX_RENDER_DISTANCE: i32 = 128;
|
||||
const INACTIVITY: &[&str] = &["minimized", "afk"];
|
||||
const CLOUDS: &[&str] = &["false", "fast", "true"];
|
||||
const MAIN_HAND: &[&str] = &["left", "right"];
|
||||
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
|
||||
b("reducedDebugInfo", false),
|
||||
def("renderClouds", O(Clouds), V::Int(2)),
|
||||
i("cloudRange", 2, 128, 128),
|
||||
i("renderDistance", 2, 64, 12),
|
||||
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
|
||||
i("simulationDistance", 5, 32, 12),
|
||||
u("screenEffectScale", 1.0),
|
||||
def("soundDevice", O(AnyStr), V::Str(String::new())),
|
||||
|
||||
@@ -84,6 +84,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
|
||||
Opt::HighContrastBlockOutlines,
|
||||
];
|
||||
|
||||
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
|
||||
/// their vanilla order. The macOS-only Control-click switch is omitted on
|
||||
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
|
||||
/// does in Java.
|
||||
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
|
||||
let mut options = vec![
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::ToggleAttack,
|
||||
Opt::ToggleUse,
|
||||
Opt::AutoJump,
|
||||
Opt::SprintWindow,
|
||||
Opt::QuitShortcuts,
|
||||
];
|
||||
if cfg!(target_os = "macos") {
|
||||
options.push(Opt::CtrlClickEmulatesRightClick);
|
||||
}
|
||||
options.push(Opt::OperatorItemsTab);
|
||||
options
|
||||
}
|
||||
|
||||
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
|
||||
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
|
||||
Opt::Sensitivity,
|
||||
@@ -120,6 +141,11 @@ impl Opt {
|
||||
| Opt::ChunkFade
|
||||
| Opt::ImprovedTransparency
|
||||
| Opt::ChunkBuilder
|
||||
// Saved, but the creative tabs, the quit keys and the mouse
|
||||
// don't read them yet.
|
||||
| Opt::OperatorItemsTab
|
||||
| Opt::QuitShortcuts
|
||||
| Opt::CtrlClickEmulatesRightClick
|
||||
)
|
||||
}
|
||||
fn file_key(self) -> Option<&'static str> {
|
||||
@@ -144,6 +170,10 @@ impl Opt {
|
||||
Opt::RotateWithMinecarts => "rotateWithMinecart",
|
||||
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
|
||||
Opt::MusicFrequency => "musicFrequency",
|
||||
Opt::SoundDevice => "soundDevice",
|
||||
Opt::OperatorItemsTab => "operatorItemsTab",
|
||||
Opt::QuitShortcuts => "quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
|
||||
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
|
||||
Opt::AllowServerListing => "allowServerListing",
|
||||
Opt::RealmsNotifications => "realmsNotifications",
|
||||
@@ -167,6 +197,7 @@ impl Opt {
|
||||
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
|
||||
Opt::AllowCursorChanges => "options.allowCursorChanges",
|
||||
Opt::Volume(_) => "soundCategory.master",
|
||||
Opt::SoundDevice => "options.audioDevice",
|
||||
Opt::GuiScale => "options.guiScale",
|
||||
Opt::ViewBobbing => "options.viewBobbing",
|
||||
Opt::Gamma => "options.gamma",
|
||||
@@ -194,7 +225,13 @@ impl Opt {
|
||||
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
|
||||
Opt::ToggleCrouch => "key.sneak",
|
||||
Opt::ToggleSprint => "key.sprint",
|
||||
Opt::ToggleAttack => "key.attack",
|
||||
Opt::ToggleUse => "key.use",
|
||||
Opt::AutoJump => "options.autoJump",
|
||||
Opt::SprintWindow => "options.sprintWindow",
|
||||
Opt::QuitShortcuts => "options.quitShortcuts",
|
||||
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
|
||||
Opt::OperatorItemsTab => "options.operatorItemsTab",
|
||||
Opt::ChatVisibility => "options.chat.visibility",
|
||||
Opt::ChatColors => "options.chat.color",
|
||||
Opt::ChatLinks => "options.chat.links",
|
||||
@@ -278,7 +315,11 @@ impl Opt {
|
||||
| Opt::GlintStrength
|
||||
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
|
||||
Opt::Fov => unit(s.fov, 30.0, 110.0),
|
||||
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
|
||||
Opt::RenderDistance => unit(
|
||||
s.render_distance as f32,
|
||||
2.0,
|
||||
crate::options_file::MAX_RENDER_DISTANCE as f32,
|
||||
),
|
||||
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
|
||||
Opt::Sensitivity => Kind::Slider(s.sensitivity),
|
||||
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
|
||||
@@ -308,6 +349,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
|
||||
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
|
||||
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
|
||||
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
|
||||
_ => Kind::Cycle,
|
||||
}
|
||||
}
|
||||
@@ -356,6 +398,14 @@ impl Opt {
|
||||
}
|
||||
}
|
||||
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
|
||||
Opt::SprintWindow => {
|
||||
let value = if s.sprint_window == 0 {
|
||||
tr("options.off")
|
||||
} else {
|
||||
Text::tr("options.value", &[&s.sprint_window])
|
||||
};
|
||||
generic(c, value)
|
||||
}
|
||||
Opt::Fov => match s.fov as i32 {
|
||||
70 => generic(c, tr("options.fov.min")),
|
||||
110 => generic(c, tr("options.fov.max")),
|
||||
@@ -573,7 +623,20 @@ impl Opt {
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleAttack => tr(if s.toggle_attack {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::ToggleUse => tr(if s.toggle_use {
|
||||
"options.key.toggle"
|
||||
} else {
|
||||
"options.key.hold"
|
||||
}),
|
||||
Opt::AutoJump => on_off(s.auto_jump),
|
||||
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
|
||||
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
|
||||
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
|
||||
Opt::ChatVisibility => tr([
|
||||
"options.chat.visibility.full",
|
||||
"options.chat.visibility.system",
|
||||
@@ -586,6 +649,13 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
|
||||
Opt::HighContrast => on_off(s.high_contrast),
|
||||
Opt::ShowSubtitles => on_off(s.show_subtitles),
|
||||
Opt::SoundDevice => {
|
||||
if s.sound_device.is_empty() {
|
||||
tr("options.audioDevice.default")
|
||||
} else {
|
||||
Text::literal(&s.sound_device)
|
||||
}
|
||||
}
|
||||
Opt::MusicFrequency => tr([
|
||||
"options.music_frequency.default",
|
||||
"options.music_frequency.frequent",
|
||||
@@ -647,7 +717,10 @@ impl Opt {
|
||||
crate::options_file::V::Double(f64::from(v)),
|
||||
),
|
||||
Opt::Fov => s.fov = step(30.0, 110.0),
|
||||
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
|
||||
Opt::RenderDistance => {
|
||||
s.render_distance =
|
||||
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
|
||||
}
|
||||
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
|
||||
Opt::Sensitivity => s.sensitivity = v,
|
||||
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
|
||||
@@ -674,6 +747,7 @@ impl Opt {
|
||||
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
|
||||
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
|
||||
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
|
||||
_ => {}
|
||||
}
|
||||
if self.affects_preset() {
|
||||
@@ -770,7 +844,14 @@ impl Opt {
|
||||
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
|
||||
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
|
||||
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
|
||||
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
|
||||
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
|
||||
Opt::AutoJump => s.auto_jump = !s.auto_jump,
|
||||
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
|
||||
Opt::CtrlClickEmulatesRightClick => {
|
||||
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
|
||||
}
|
||||
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
|
||||
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
|
||||
Opt::ChatColors => s.chat_colors = !s.chat_colors,
|
||||
Opt::ChatLinks => s.chat_links = !s.chat_links,
|
||||
@@ -779,6 +860,23 @@ impl Opt {
|
||||
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
|
||||
Opt::HighContrast => s.high_contrast = !s.high_contrast,
|
||||
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
|
||||
Opt::SoundDevice => {
|
||||
let devices = crate::audio::output_devices();
|
||||
let mut all = vec![String::new()];
|
||||
for d in devices {
|
||||
if !all.contains(&d) {
|
||||
all.push(d);
|
||||
}
|
||||
}
|
||||
let current_idx = all.iter().position(|d| d == &s.sound_device).unwrap_or(0);
|
||||
let count = all.len();
|
||||
let next_idx = if back {
|
||||
(current_idx + count - 1) % count
|
||||
} else {
|
||||
(current_idx + 1) % count
|
||||
};
|
||||
s.sound_device = all[next_idx].clone();
|
||||
}
|
||||
Opt::MusicFrequency => {
|
||||
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
|
||||
s.file
|
||||
@@ -921,6 +1019,7 @@ pub enum Page {
|
||||
/// Horizon's graphics settings (`graphics_ui`).
|
||||
Graphics,
|
||||
GraphicsAdvanced,
|
||||
GraphicsHdr,
|
||||
/// Horizon's controller settings and button binds.
|
||||
Controller,
|
||||
PadBinds,
|
||||
@@ -955,8 +1054,7 @@ enum Act {
|
||||
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
|
||||
PickShaderPack(usize),
|
||||
OpenShaderPackFolder,
|
||||
/// The installed mod's own settings page (capability `ui`), shown as a
|
||||
/// screen: `Event::ModSettings` has the game open it.
|
||||
/// The installed native mod's settings page.
|
||||
ModSettings(usize),
|
||||
/// A native mod's key (mod, key) waits for a key; a button of its page.
|
||||
NativeKey(usize, usize),
|
||||
@@ -1023,9 +1121,6 @@ pub enum Event {
|
||||
/// A shader pack was picked or turned off: save and apply it.
|
||||
ShaderPack,
|
||||
OpenShaderPackFolder,
|
||||
/// Open the mod's own settings page (capability `ui`): the installed
|
||||
/// mod's index in `mods.installed`.
|
||||
ModSettings(usize),
|
||||
/// Native mod `.0`'s page opened: the game asks the mod for its labels
|
||||
/// again (they were made with the list, maybe in another language).
|
||||
NativeSettings(usize),
|
||||
@@ -1295,7 +1390,10 @@ impl OptionsUi {
|
||||
if page == Page::Packs {
|
||||
self.pack_screen = None;
|
||||
}
|
||||
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
|
||||
if matches!(
|
||||
page,
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
|
||||
) {
|
||||
self.graphics.reset();
|
||||
}
|
||||
}
|
||||
@@ -1304,6 +1402,7 @@ impl OptionsUi {
|
||||
match page {
|
||||
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
|
||||
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
|
||||
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1325,7 +1424,7 @@ impl OptionsUi {
|
||||
Page::Mods => "Mods",
|
||||
Page::NativeMod(_) => "Mod Settings",
|
||||
Page::ShaderPacks => "Shader Packs",
|
||||
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
|
||||
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
|
||||
Page::Controller => "Controller Settings",
|
||||
Page::PadBinds => "Controller Binds",
|
||||
Page::Extras => "Horizon Extras",
|
||||
@@ -1580,6 +1679,7 @@ impl OptionsUi {
|
||||
rows.extend(pairs(&others));
|
||||
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
|
||||
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
|
||||
rows.push(Row::Big(Opt::SoundDevice));
|
||||
rows
|
||||
}
|
||||
Page::Skin => {
|
||||
@@ -1615,11 +1715,8 @@ impl OptionsUi {
|
||||
Act::Push(Page::KeyBinds),
|
||||
)),
|
||||
)];
|
||||
rows.extend(pairs(&[
|
||||
Opt::ToggleCrouch,
|
||||
Opt::ToggleSprint,
|
||||
Opt::AutoJump,
|
||||
]));
|
||||
let controls = vanilla_control_options();
|
||||
rows.extend(pairs(&controls));
|
||||
// Horizon's controller page, after vanilla's rows.
|
||||
rows.push(Row::Buttons(
|
||||
button(
|
||||
@@ -1714,9 +1811,9 @@ impl OptionsUi {
|
||||
label = label.append(" (Dev)");
|
||||
}
|
||||
let dead = installed.dev || installed.id.is_empty();
|
||||
// A running mod with a settings page (capability
|
||||
// `ui`) gets its own button beside the toggle.
|
||||
if installed.has_settings || installed.native.is_some() {
|
||||
// A native mod gets its settings page's button beside
|
||||
// the toggle.
|
||||
if installed.native.is_some() {
|
||||
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
|
||||
let settings = button(
|
||||
0.0,
|
||||
@@ -2587,6 +2684,7 @@ impl OptionsUi {
|
||||
Action::Changed => events.push(Event::Changed),
|
||||
Action::SliderDrag => events.push(Event::SliderDrag),
|
||||
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
|
||||
Action::OpenHdr => self.push(Page::GraphicsHdr),
|
||||
Action::OpenSimple => {
|
||||
if self.stack.len() >= 2
|
||||
&& self.stack[self.stack.len() - 2] == Page::Graphics
|
||||
@@ -2772,12 +2870,8 @@ impl OptionsUi {
|
||||
}
|
||||
Act::ModSettings(i) => {
|
||||
events.push(Event::ButtonClick);
|
||||
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
} else {
|
||||
events.push(Event::ModSettings(i));
|
||||
}
|
||||
self.push(Page::NativeMod(i));
|
||||
events.push(Event::NativeSettings(i));
|
||||
}
|
||||
Act::NativeKey(i, k) => {
|
||||
self.selected_key = Some(NATIVE_KEY);
|
||||
@@ -3223,10 +3317,48 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_match_java_26_3_option_order() {
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Controls);
|
||||
let options =
|
||||
ui.list_rows(&Settings::default())
|
||||
.into_iter()
|
||||
.fold(Vec::new(), |mut options, row| {
|
||||
match row {
|
||||
Row::Big(opt) => options.push(opt),
|
||||
Row::Pair(a, b) => {
|
||||
options.push(a);
|
||||
options.extend(b);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
options
|
||||
});
|
||||
assert_eq!(options, vanilla_control_options());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_window_and_new_control_switches_round_trip() {
|
||||
let mut settings = Settings::default();
|
||||
set_slider(Opt::SprintWindow, &mut settings, 0.5);
|
||||
settings.operator_items_tab = true;
|
||||
settings.quit_shortcuts = false;
|
||||
settings.ctrl_click_emulates_right_click = true;
|
||||
settings.toggle_attack = true;
|
||||
settings.toggle_use = true;
|
||||
let loaded = Settings::parse(&settings.serialize());
|
||||
assert_eq!(loaded.sprint_window, 5);
|
||||
assert!(loaded.operator_items_tab);
|
||||
assert!(!loaded.quit_shortcuts);
|
||||
assert!(loaded.ctrl_click_emulates_right_click);
|
||||
assert!(loaded.toggle_attack && loaded.toggle_use);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_distance_slider_round_trips_through_extended_options_range() {
|
||||
let mut s = Settings::default();
|
||||
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
|
||||
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
|
||||
set_slider(Opt::RenderDistance, &mut s, position);
|
||||
assert_eq!(s.render_distance, distance);
|
||||
let reloaded = Settings::parse(&s.serialize());
|
||||
@@ -3237,10 +3369,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
let mut file = crate::options_file::OptionsFile::default();
|
||||
file.set_text("renderDistance", "64").unwrap();
|
||||
assert!(file.set_text("renderDistance", "65").is_err());
|
||||
file.set_text("renderDistance", "128").unwrap();
|
||||
assert!(file.set_text("renderDistance", "129").is_err());
|
||||
assert!(file.set_text("renderDistance", "1").is_err());
|
||||
assert_eq!(file.int("renderDistance"), 64);
|
||||
assert_eq!(file.int("renderDistance"), 128);
|
||||
}
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
@@ -3348,7 +3480,6 @@ mod tests {
|
||||
description: "May show HUD panels.".into(),
|
||||
problem: id.is_empty().then(|| "Invalid package".into()),
|
||||
dev,
|
||||
has_settings: false,
|
||||
native: None,
|
||||
});
|
||||
}
|
||||
@@ -3416,7 +3547,6 @@ mod tests {
|
||||
description: String::new(),
|
||||
problem: None,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: Some(crate::mod_list::NativePage::default()),
|
||||
});
|
||||
ui.show(Page::Mods);
|
||||
@@ -4490,4 +4620,29 @@ mod tests {
|
||||
ui.stack = vec![Page::Main, Page::Controls];
|
||||
assert!(pushes(&mut ui).contains(&Page::Controller));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_device_button_in_sound_page_and_cycles() {
|
||||
let mut s = Settings::default();
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::Sound);
|
||||
let rows = ui.list_rows(&s);
|
||||
assert!(
|
||||
rows.iter().any(|r| matches!(r, Row::Big(Opt::SoundDevice))),
|
||||
"Sound page should contain Row::Big(Opt::SoundDevice)"
|
||||
);
|
||||
assert_eq!(s.sound_device, "");
|
||||
let devices = crate::audio::output_devices();
|
||||
let count = 1 + devices.len();
|
||||
// Cycling forward 'count' times wraps around back to the initial selection ("")
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, false, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
// Cycling backward 'count' times also wraps around back to ""
|
||||
for _ in 0..count {
|
||||
Opt::SoundDevice.cycle(&mut s, true, [854, 480]);
|
||||
}
|
||||
assert_eq!(s.sound_device, "");
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
use super::{Pack, Source, build_overlay_uncached};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
#[cfg(any(test, not(unix)))]
|
||||
use std::io::Read;
|
||||
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
|
||||
@@ -60,7 +59,6 @@ fn field(hash: &mut Sha256, bytes: &[u8]) {
|
||||
hash.update((bytes.len() as u64).to_le_bytes());
|
||||
hash.update(bytes);
|
||||
}
|
||||
#[cfg(any(test, not(unix)))]
|
||||
fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
hash.update(file.metadata()?.len().to_le_bytes());
|
||||
@@ -238,7 +236,10 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
|
||||
}
|
||||
Source::Zip(path) => {
|
||||
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
|
||||
stamp(&mut hash, path)?;
|
||||
// By its bytes, not its stamp: a server pack found in the download cache is
|
||||
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
|
||||
// time rebuilt the whole overlay each time. One file reads in a moment.
|
||||
content(&mut hash, path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +350,27 @@ mod tests {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
/// Waits for the filesystem's clock to move on, as it does between a build and a
|
||||
/// person's next edit. On Unix the cache compares stamps (length, mtime, ctime, inode),
|
||||
/// and Linux takes them from a coarse clock (a few milliseconds a tick): an edit in the
|
||||
/// tick of the build before kept them all, and the tests failed at random.
|
||||
fn next_tick(&self) {
|
||||
let probe = self.0.with_extension("tick");
|
||||
// New bytes each time: NTFS keeps the time of a write that changes nothing.
|
||||
let stamp = |n: u32| {
|
||||
fs::write(&probe, n.to_le_bytes()).unwrap();
|
||||
fs::metadata(&probe).unwrap().modified().unwrap()
|
||||
};
|
||||
let start = stamp(0);
|
||||
// A second at most: no clock is that coarse, and a test never hangs on one.
|
||||
for n in 1..1000 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
if stamp(n) != start {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = fs::remove_file(&probe);
|
||||
}
|
||||
fn dir_pack(&self, name: &str) -> Pack {
|
||||
self.write(
|
||||
&format!("{name}/pack.mcmeta"),
|
||||
@@ -371,6 +393,7 @@ mod tests {
|
||||
output_signature(&cached).unwrap(),
|
||||
output_signature(&self.0.join("reference")).unwrap()
|
||||
);
|
||||
self.next_tick();
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
@@ -647,6 +670,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A ZIP whose bytes are the same keeps its overlay when only its time changes: a server
|
||||
/// pack found in the download cache is marked used on every join
|
||||
/// (`HttpUtil.updateModificationTime`), and rebuilding the overlay took most of a minute.
|
||||
#[test]
|
||||
fn a_zip_touched_but_unchanged_keeps_its_overlay() {
|
||||
let f = Fixture::new();
|
||||
f.write("vanilla/assets/minecraft/textures/b", b"vanilla");
|
||||
let files = vec![
|
||||
(
|
||||
"pack.mcmeta",
|
||||
br#"{"pack":{"description":"test","pack_format":97}}"#.to_vec(),
|
||||
),
|
||||
("assets/minecraft/textures/a", b"aaa".to_vec()),
|
||||
];
|
||||
f.write("pack.zip", &archive(&files, false));
|
||||
let zip = f.0.join("pack.zip");
|
||||
let pack = packs::pack("zip".into(), Text::empty(), Source::Zip(zip.clone())).unwrap();
|
||||
let (vanilla, out) = (f.0.join("vanilla"), f.0.join("cached"));
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
// A rebuild empties the output first: this file survives a cache hit only.
|
||||
fs::write(out.join("kept"), b"").unwrap();
|
||||
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
|
||||
fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&zip)
|
||||
.unwrap()
|
||||
.set_modified(later)
|
||||
.unwrap();
|
||||
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
|
||||
assert!(out.join("kept").exists(), "the overlay was rebuilt");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unrelated_directory_pack_symlink_cycle_disables_reuse() {
|
||||
|
||||
@@ -101,6 +101,8 @@ pub struct Settings {
|
||||
pub master_volume: f32,
|
||||
/// Volumes of every other sound source, indexed like SOUND_SOURCES.
|
||||
pub volumes: [f32; 11],
|
||||
/// Empty for system default, or a specific audio device name.
|
||||
pub sound_device: String,
|
||||
/// Requested scale (0 = auto); rendering caps it to the current framebuffer.
|
||||
pub gui_scale: u32,
|
||||
pub view_bobbing: bool,
|
||||
@@ -174,6 +176,12 @@ pub struct Settings {
|
||||
pub toggle_use: bool,
|
||||
/// Taps of the sprint key counted as double taps (sprintWindow).
|
||||
pub sprint_window: i32,
|
||||
/// Show the operator items tab in a creative inventory.
|
||||
pub operator_items_tab: bool,
|
||||
/// Allow platform quit shortcuts to close the game window.
|
||||
pub quit_shortcuts: bool,
|
||||
/// On macOS, turn Control-click into a right click.
|
||||
pub ctrl_click_emulates_right_click: bool,
|
||||
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
|
||||
pub horizon: crate::graphics::Graphics,
|
||||
/// Controller settings (`horizon-controller.txt`, Horizon's own).
|
||||
@@ -195,6 +203,7 @@ impl Default for Settings {
|
||||
sensitivity: 0.5,
|
||||
master_volume: 1.0,
|
||||
volumes: [1.0; 11],
|
||||
sound_device: String::new(),
|
||||
gui_scale: 0,
|
||||
view_bobbing: true,
|
||||
gamma: 0.5,
|
||||
@@ -256,6 +265,9 @@ impl Default for Settings {
|
||||
toggle_attack: false,
|
||||
toggle_use: false,
|
||||
sprint_window: 7,
|
||||
operator_items_tab: false,
|
||||
quit_shortcuts: true,
|
||||
ctrl_click_emulates_right_click: false,
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -383,6 +395,7 @@ impl Settings {
|
||||
sensitivity: f.double("mouseSensitivity") as f32,
|
||||
master_volume: volumes[0],
|
||||
volumes,
|
||||
sound_device: f.string("soundDevice"),
|
||||
gui_scale: f.int("guiScale").max(0) as u32,
|
||||
view_bobbing: f.bool("bobView"),
|
||||
gamma: f.double("gamma") as f32,
|
||||
@@ -418,6 +431,9 @@ impl Settings {
|
||||
toggle_attack: f.bool("toggleAttack"),
|
||||
toggle_use: f.bool("toggleUse"),
|
||||
sprint_window: f.int("sprintWindow"),
|
||||
operator_items_tab: f.bool("operatorItemsTab"),
|
||||
quit_shortcuts: f.bool("quitShortcuts"),
|
||||
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
|
||||
horizon: Default::default(),
|
||||
controller: Default::default(),
|
||||
extras: Default::default(),
|
||||
@@ -475,6 +491,7 @@ impl Settings {
|
||||
};
|
||||
put_f(&mut f, &format!("soundCategory_{source}"), v);
|
||||
}
|
||||
put_s(&mut f, "soundDevice", &self.sound_device);
|
||||
put_i(&mut f, "guiScale", self.gui_scale as i32);
|
||||
put_b(&mut f, "bobView", self.view_bobbing);
|
||||
put_f(&mut f, "gamma", self.gamma);
|
||||
@@ -518,6 +535,13 @@ impl Settings {
|
||||
put_b(&mut f, "toggleAttack", self.toggle_attack);
|
||||
put_b(&mut f, "toggleUse", self.toggle_use);
|
||||
put_i(&mut f, "sprintWindow", self.sprint_window);
|
||||
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
|
||||
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
|
||||
put_b(
|
||||
&mut f,
|
||||
"ctrlClickEmulatesRightClick",
|
||||
self.ctrl_click_emulates_right_click,
|
||||
);
|
||||
put_b(&mut f, "autoJump", self.auto_jump);
|
||||
put_s(&mut f, "lang", &self.language);
|
||||
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
|
||||
@@ -637,6 +661,7 @@ pub enum Opt {
|
||||
InvertY,
|
||||
AllowCursorChanges,
|
||||
Volume(usize),
|
||||
SoundDevice,
|
||||
GuiScale,
|
||||
ViewBobbing,
|
||||
Gamma,
|
||||
@@ -664,7 +689,13 @@ pub enum Opt {
|
||||
MenuBlur,
|
||||
ToggleCrouch,
|
||||
ToggleSprint,
|
||||
ToggleAttack,
|
||||
ToggleUse,
|
||||
AutoJump,
|
||||
SprintWindow,
|
||||
QuitShortcuts,
|
||||
CtrlClickEmulatesRightClick,
|
||||
OperatorItemsTab,
|
||||
ChatVisibility,
|
||||
ChatColors,
|
||||
ChatLinks,
|
||||
@@ -820,4 +851,15 @@ mod tests {
|
||||
1.0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn sound_device_option() {
|
||||
let s = Settings::parse("soundDevice:\"Headphones\"\n");
|
||||
assert_eq!(s.sound_device, "Headphones");
|
||||
let serialized = s.serialize();
|
||||
assert!(serialized.contains("soundDevice:\"Headphones\""));
|
||||
let again = Settings::parse(&serialized);
|
||||
assert_eq!(again.sound_device, "Headphones");
|
||||
let empty = Settings::parse("");
|
||||
assert_eq!(empty.sound_device, "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
|
||||
//! its window (`place`).
|
||||
use winit::dpi::PhysicalSize;
|
||||
|
||||
/// Players at most: P1 and fifteen more.
|
||||
pub const MAX_PLAYERS: usize = 16;
|
||||
|
||||
/// A player's view: its window (an index into the windows), its corner there and its size.
|
||||
pub type View = (usize, [u32; 2], PhysicalSize<u32>);
|
||||
|
||||
/// Rows of five to sixteen players sharing a window, widest first (columns on a tall window): every
|
||||
/// view stays near a screen's shape, where a full grid would leave a long thin one.
|
||||
const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
|
||||
&[3, 2],
|
||||
&[3, 3],
|
||||
&[3, 2, 2],
|
||||
&[3, 3, 2],
|
||||
&[3, 3, 3],
|
||||
&[4, 3, 3],
|
||||
&[4, 4, 3],
|
||||
&[4, 4, 4],
|
||||
&[4, 3, 3, 3],
|
||||
&[4, 4, 3, 3],
|
||||
&[4, 4, 4, 3],
|
||||
&[4, 4, 4, 4],
|
||||
];
|
||||
|
||||
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
|
||||
/// others, P1 first.
|
||||
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
|
||||
let mut out: Vec<usize> = Vec::with_capacity(n);
|
||||
for &i in order {
|
||||
if i < n && !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out.extend((0..n).filter(|i| !order.contains(i)));
|
||||
out
|
||||
}
|
||||
|
||||
/// `place` with the players sharing a window in `order` (`ordered`): the first left or on top.
|
||||
/// The views are still by player.
|
||||
pub fn place_ordered(
|
||||
windows: &[usize],
|
||||
order: &[usize],
|
||||
sizes: &[PhysicalSize<u32>],
|
||||
two: u8,
|
||||
three: u8,
|
||||
) -> Vec<View> {
|
||||
let order = ordered(order, windows.len());
|
||||
let in_order: Vec<usize> = order.iter().map(|&i| windows[i]).collect();
|
||||
let placed = place(&in_order, sizes, two, three);
|
||||
let mut views = placed.clone();
|
||||
for (k, &i) in order.iter().enumerate() {
|
||||
views[i] = placed[k];
|
||||
}
|
||||
views
|
||||
}
|
||||
|
||||
/// Each player's view, given the window each is in and the windows' sizes. Players in one window
|
||||
/// share it in the order they come: two take halves (`two`: 0 side by side when wider than
|
||||
/// tall, 1 side by side, 2 top and bottom); three (`three`: 0 the first wide on top and two below,
|
||||
/// 1 the first tall on the left and two on the right, 2 three columns); four its quarters; five
|
||||
/// to sixteen in balanced rows (`ROWS`).
|
||||
pub fn place(windows: &[usize], sizes: &[PhysicalSize<u32>], two: u8, three: u8) -> Vec<View> {
|
||||
windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &window)| {
|
||||
let here = windows.iter().filter(|&&w| w == window).count();
|
||||
let slot = windows[..i].iter().filter(|&&w| w == window).count() as u32;
|
||||
let size = sizes
|
||||
.get(window)
|
||||
.copied()
|
||||
.unwrap_or(PhysicalSize::new(1, 1));
|
||||
let (w, h) = (size.width, size.height);
|
||||
let side_by_side = match two {
|
||||
1 => true,
|
||||
2 => false,
|
||||
_ => w >= h,
|
||||
};
|
||||
// The view's cell as fractions of the window: (x0, x1, y0, y1) in halves or thirds.
|
||||
let cell = |x0: u32, x1: u32, y0: u32, y1: u32, of: u32| {
|
||||
[w * x0 / of, w * x1 / of, h * y0 / of, h * y1 / of]
|
||||
};
|
||||
let [x0, x1, y0, y1] = match (here, slot) {
|
||||
(1, _) => cell(0, 1, 0, 1, 1),
|
||||
(2, s) if side_by_side => cell(s, s + 1, 0, 2, 2),
|
||||
(2, s) => cell(0, 2, s, s + 1, 2),
|
||||
(3, s) => match (three, s) {
|
||||
(2, s) => cell(s, s + 1, 0, 3, 3),
|
||||
(1, 0) => cell(0, 1, 0, 2, 2),
|
||||
(1, s) => cell(1, 2, s - 1, s, 2),
|
||||
(_, 0) => cell(0, 2, 0, 1, 2),
|
||||
(_, s) => cell(s - 1, s, 1, 2, 2),
|
||||
},
|
||||
(4, s) => cell(s % 2, s % 2 + 1, s / 2, s / 2 + 1, 2),
|
||||
(n, s) => rows(n, s, w, h),
|
||||
};
|
||||
(
|
||||
window,
|
||||
[x0, y0],
|
||||
PhysicalSize::new((x1 - x0).max(1), (y1 - y0).max(1)),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Five to sixteen players: `slot`'s cell `[x0, x1, y0, y1]` in balanced rows (`ROWS`), or
|
||||
/// in columns when the window is taller than wide.
|
||||
fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
|
||||
let counts = ROWS[n.clamp(5, MAX_PLAYERS) - 5];
|
||||
let bands = counts.len() as u32;
|
||||
let (mut band, mut k) = (0, slot);
|
||||
while band + 1 < bands && k >= counts[band as usize] {
|
||||
k -= counts[band as usize];
|
||||
band += 1;
|
||||
}
|
||||
let across = counts[band as usize];
|
||||
// Along: the long side, divided between a band's views; down: the bands.
|
||||
let (along, down) = if w >= h { (w, h) } else { (h, w) };
|
||||
let (a0, a1) = (along * k / across, along * (k + 1) / across);
|
||||
let (d0, d1) = (down * band / bands, down * (band + 1) / bands);
|
||||
if w >= h {
|
||||
[a0, a1, d0, d1]
|
||||
} else {
|
||||
[d0, d1, a0, a1]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
|
||||
PhysicalSize::new(w, h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_on_one_screen_share_it() {
|
||||
let wide = [size(1920, 1080)];
|
||||
assert_eq!(
|
||||
place(&[0, 0], &wide, 0, 0)[1],
|
||||
(0, [960, 0], size(960, 1080))
|
||||
);
|
||||
assert_eq!(
|
||||
place(&[0, 0], &wide, 2, 0)[1],
|
||||
(0, [0, 540], size(1920, 540))
|
||||
);
|
||||
// Auto on a tall screen: top and bottom.
|
||||
assert_eq!(place(&[0, 0], &[size(1080, 1920)], 0, 0)[1].1, [0, 960]);
|
||||
assert_eq!(
|
||||
place(&[0; 4], &wide, 0, 0)[3],
|
||||
(0, [960, 540], size(960, 540))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desk_and_couch() {
|
||||
// P1 alone at the desk; P2 and P3 share the TV.
|
||||
let screens = [size(3024, 1964), size(3840, 2160)];
|
||||
let views = place(&[0, 1, 1], &screens, 0, 0);
|
||||
assert_eq!(views[0], (0, [0, 0], size(3024, 1964)));
|
||||
assert_eq!(views[1], (1, [0, 0], size(1920, 2160)));
|
||||
assert_eq!(views[2], (1, [1920, 0], size(1920, 2160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_on_a_screen() {
|
||||
let tv = [size(1920, 1080)];
|
||||
// One wide on top, two below.
|
||||
let top = place(&[0; 3], &tv, 0, 0);
|
||||
assert_eq!(top[0], (0, [0, 0], size(1920, 540)));
|
||||
assert_eq!(top[2], (0, [960, 540], size(960, 540)));
|
||||
// One tall on the left, two stacked on the right.
|
||||
let left = place(&[0; 3], &tv, 0, 1);
|
||||
assert_eq!(left[0], (0, [0, 0], size(960, 1080)));
|
||||
assert_eq!(left[2], (0, [960, 540], size(960, 540)));
|
||||
// Three columns.
|
||||
assert_eq!(
|
||||
place(&[0; 3], &tv, 0, 2)[2],
|
||||
(0, [1280, 0], size(640, 1080))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_windows_two_players_each() {
|
||||
// P1 and P2 at the desk, P3 and P4 on the TV: halves in each window.
|
||||
let screens = [size(3024, 1964), size(3840, 2160)];
|
||||
let views = place(&[0, 0, 1, 1], &screens, 0, 0);
|
||||
assert_eq!(views[1], (0, [1512, 0], size(1512, 1964)));
|
||||
assert_eq!(views[2], (1, [0, 0], size(1920, 2160)));
|
||||
assert_eq!(views[3], (1, [1920, 0], size(1920, 2160)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_to_eight_in_rows_of_three_and_two() {
|
||||
let tv = [size(1920, 1080)];
|
||||
let eight = place(&[0; 8], &tv, 0, 0);
|
||||
assert_eq!(eight[0], (0, [0, 0], size(640, 360)));
|
||||
assert_eq!(eight[5], (0, [1280, 360], size(640, 360)));
|
||||
assert_eq!(eight[7], (0, [960, 720], size(960, 360)));
|
||||
let seven = place(&[0; 7], &tv, 0, 0);
|
||||
assert_eq!(seven[3], (0, [0, 360], size(960, 360)));
|
||||
// Every count tiles the window exactly: no gap, no overlap.
|
||||
for n in 5..=MAX_PLAYERS {
|
||||
for screen in [size(1920, 1080), size(1080, 1920), size(1711, 997)] {
|
||||
let views = place(&vec![0; n], &[screen], 0, 0);
|
||||
for (i, (_, [x, y], size)) in views.iter().enumerate() {
|
||||
assert!(x + size.width <= screen.width && y + size.height <= screen.height);
|
||||
for (_, [ox, oy], other) in &views[..i] {
|
||||
assert!(
|
||||
x + size.width <= *ox
|
||||
|| ox + other.width <= *x
|
||||
|| y + size.height <= *oy
|
||||
|| oy + other.height <= *y,
|
||||
"overlapping views for {n} in {screen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let area: u64 = views
|
||||
.iter()
|
||||
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
|
||||
.sum();
|
||||
assert_eq!(
|
||||
area,
|
||||
u64::from(screen.width) * u64::from(screen.height),
|
||||
"{n} in {screen:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A tall window: columns.
|
||||
let tall = place(&[0; 5], &[size(1080, 1920)], 0, 0);
|
||||
assert_eq!(tall[0], (0, [0, 0], size(540, 640)));
|
||||
assert_eq!(tall[4], (0, [540, 960], size(540, 960)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sixteen_players_form_a_four_by_four_grid() {
|
||||
let views = place(&[0; 16], &[size(3840, 2160)], 0, 0);
|
||||
for (i, view) in views.iter().enumerate() {
|
||||
assert_eq!(
|
||||
*view,
|
||||
(
|
||||
0,
|
||||
[(i as u32 % 4) * 960, (i as u32 / 4) * 540],
|
||||
size(960, 540)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_order_names_each_player_once_and_the_others_come_last() {
|
||||
assert_eq!(ordered(&[], 3), [0, 1, 2]);
|
||||
assert_eq!(ordered(&[2, 0], 3), [2, 0, 1]);
|
||||
// Gone players and repeats are dropped.
|
||||
assert_eq!(ordered(&[5, 1, 1], 3), [1, 0, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn players_sharing_a_window_fill_it_in_their_order() {
|
||||
let wide = [size(2000, 1000)];
|
||||
// P2 first: P2 on the left, P1 on the right.
|
||||
let views = place_ordered(&[0, 0], &[1, 0], &wide, 0, 0);
|
||||
assert_eq!(views[1].1, [0, 0]);
|
||||
assert_eq!(views[0].1, [1000, 0]);
|
||||
// P3 first of three: the wide view on top is P3's.
|
||||
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
|
||||
assert_eq!(views[2].2, size(2000, 500));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,10 @@ use horizon_core::biome::BiomeZoom;
|
||||
use horizon_world::block_render::FaceVisibility;
|
||||
use horizon_world::worldgen::decoration_order::DecorationOrder;
|
||||
use std::{
|
||||
collections::{BinaryHeap, HashSet},
|
||||
collections::{BinaryHeap, HashSet, VecDeque},
|
||||
sync::{
|
||||
Arc, Condvar, Mutex, RwLock,
|
||||
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
};
|
||||
@@ -171,6 +170,9 @@ enum Job {
|
||||
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
||||
/// off the main thread like vanilla's IOWorker.
|
||||
Save(Vec<((i32, i32), Proto)>),
|
||||
/// Meshes the renderer is done with (`Streaming::free`): their memory
|
||||
/// is given back here, not in the frame.
|
||||
Free(Vec<((i32, i32, i32), SectionMesh)>),
|
||||
}
|
||||
pub enum Done {
|
||||
Generated(Chunk),
|
||||
@@ -217,11 +219,52 @@ struct Shared {
|
||||
/// What meshing reads (block models and sprites), swapped when resource
|
||||
/// packs reload; each mesh job takes the current one.
|
||||
context: RwLock<Arc<WorkerContext>>,
|
||||
done: Mailbox,
|
||||
}
|
||||
/// Finished jobs, from the workers to the game thread, in the order they
|
||||
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
||||
/// half way through a send, and a worker descheduled there on a busy machine
|
||||
/// held the frame for a scheduler quantum (10 to 30 ms, the RD 32 ocean
|
||||
/// flight's 1% lows). The frame takes what is there without waiting; while a
|
||||
/// worker holds the lock, it takes it the next frame.
|
||||
#[derive(Default)]
|
||||
struct Mailbox {
|
||||
done: Mutex<VecDeque<Done>>,
|
||||
arrived: Condvar,
|
||||
}
|
||||
impl Mailbox {
|
||||
fn send(&self, done: Done) {
|
||||
self.done.lock().unwrap().push_back(done);
|
||||
self.arrived.notify_one();
|
||||
}
|
||||
fn try_lock(&self) -> Option<MutexGuard<'_, VecDeque<Done>>> {
|
||||
match self.done.try_lock() {
|
||||
Ok(done) => Some(done),
|
||||
Err(TryLockError::WouldBlock) => None,
|
||||
Err(TryLockError::Poisoned(e)) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
/// Every finished job, or none while a worker is sending.
|
||||
fn take(&self) -> VecDeque<Done> {
|
||||
self.try_lock()
|
||||
.map(|mut done| std::mem::take(&mut *done))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn try_recv(&self) -> Option<Done> {
|
||||
self.try_lock()?.pop_front()
|
||||
}
|
||||
fn recv_timeout(&self, timeout: std::time::Duration) -> Option<Done> {
|
||||
let done = self.done.lock().unwrap();
|
||||
let (mut done, _) = self
|
||||
.arrived
|
||||
.wait_timeout_while(done, timeout, |done| done.is_empty())
|
||||
.unwrap();
|
||||
done.pop_front()
|
||||
}
|
||||
}
|
||||
pub struct Streaming {
|
||||
shared: Arc<Shared>,
|
||||
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
|
||||
results: mpsc::Receiver<Done>,
|
||||
threads: Vec<thread::JoinHandle<()>>,
|
||||
order: u64,
|
||||
/// Ticks since the columns and protos that fell out of range were swept.
|
||||
@@ -458,14 +501,13 @@ impl Streaming {
|
||||
ready: Condvar::new(),
|
||||
stop: AtomicBool::new(false),
|
||||
context: RwLock::new(context.clone()),
|
||||
done: Mailbox::default(),
|
||||
});
|
||||
let (send, results) = mpsc::channel();
|
||||
let light_tables = context.light.clone();
|
||||
let saves: Arc<dyn Saves> = generator.clone();
|
||||
let threads = (0..threads.max(1))
|
||||
.map(|i| {
|
||||
let shared = shared.clone();
|
||||
let send = send.clone();
|
||||
let generator = generator.clone();
|
||||
let light = context.light.clone();
|
||||
thread::Builder::new()
|
||||
@@ -513,6 +555,10 @@ impl Streaming {
|
||||
generator.save_protos(&refs);
|
||||
Done::Saved(protos)
|
||||
}
|
||||
Job::Free(meshes) => {
|
||||
drop(meshes);
|
||||
continue;
|
||||
}
|
||||
Job::Light(x, z, region, seq, sky) => {
|
||||
Done::Lit(x, z, light_chunk(&borrow(®ion), &light, sky), seq)
|
||||
}
|
||||
@@ -544,9 +590,7 @@ impl Streaming {
|
||||
Done::Meshed(x, sy, z, mesh, seq)
|
||||
}
|
||||
};
|
||||
if send.send(done).is_err() {
|
||||
return;
|
||||
}
|
||||
shared.done.send(done);
|
||||
}
|
||||
})
|
||||
.expect("spawn worker")
|
||||
@@ -559,7 +603,6 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
results,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
@@ -663,6 +706,17 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Give back meshes the renderer has copied: a worker frees them, ahead of
|
||||
/// other jobs, as vanilla's compile task closes its mesh data on its own
|
||||
/// thread once copied (`SectionRenderDispatcher.RenderSection.CompileTask.doTask`).
|
||||
/// Freed in the frame, the hundreds of meshes a chunk border brings waited
|
||||
/// on the allocator behind every busy worker: up to 20 ms of a frame each
|
||||
/// time while flying at render distance 32.
|
||||
pub fn free(&mut self, meshes: Vec<((i32, i32, i32), SectionMesh)>) {
|
||||
if !meshes.is_empty() {
|
||||
self.submit(None, 0, Job::Free(meshes));
|
||||
}
|
||||
}
|
||||
/// A column the server unloaded (ClientboundForgetLevelChunkPacket).
|
||||
pub fn forget(&mut self, world: &mut World, x: i32, z: i32) -> Option<Arc<Chunk>> {
|
||||
self.lit.remove(&(x, z));
|
||||
@@ -679,7 +733,7 @@ impl Streaming {
|
||||
}
|
||||
/// Queue a job for the column `at`, nearest first: its distance (`distance`)
|
||||
/// times four plus `bias`, which orders a column's jobs. A job without a
|
||||
/// column (a save) goes ahead of every other.
|
||||
/// column (a save, meshes to free) goes ahead of every other.
|
||||
fn submit(&mut self, at: Option<(i32, i32)>, bias: i64, job: Job) {
|
||||
self.order += 1;
|
||||
let priority = at.map_or(i64::MIN, |(x, z)| self.distance(x, z) * 4 + bias);
|
||||
@@ -912,8 +966,10 @@ impl Streaming {
|
||||
self.requeue();
|
||||
self.relight_edits(world);
|
||||
let deferred = std::mem::take(&mut self.deferred);
|
||||
let received = std::iter::from_fn(|| self.results.try_recv().ok());
|
||||
for done in deferred.into_iter().chain(received.collect::<Vec<_>>()) {
|
||||
let received = self.shared.done.take();
|
||||
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
|
||||
let mut stale = Vec::new();
|
||||
for done in deferred.into_iter().chain(received) {
|
||||
match done {
|
||||
Done::Proto(x, z, proto, decorated) => {
|
||||
self.generation_scan.exhausted = false;
|
||||
@@ -969,12 +1025,14 @@ impl Streaming {
|
||||
}
|
||||
Done::Meshed(x, sy, z, mesh, seq) => {
|
||||
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
|
||||
stale.push(((x, sy, z), mesh));
|
||||
continue;
|
||||
}
|
||||
// Its column unloaded while it was meshed: nothing would
|
||||
// ever remove the uploaded section.
|
||||
if !world.chunks.contains_key(&(x, z)) {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
stale.push(((x, sy, z), mesh));
|
||||
continue;
|
||||
}
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
@@ -983,6 +1041,7 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.free(stale);
|
||||
let mut removed = Vec::new();
|
||||
if sweep {
|
||||
// Unload far columns.
|
||||
@@ -1421,12 +1480,12 @@ impl Streaming {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.results.recv_timeout(left) {
|
||||
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Ok(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Ok(other) => self.deferred.push(other),
|
||||
Err(_) => {
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
|
||||
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
||||
Some(Done::Saved(protos)) => self.receive_saved(protos),
|
||||
Some(other) => self.deferred.push(other),
|
||||
None => {
|
||||
log::warn!("Horizon: chunk generation did not finish before saving");
|
||||
break;
|
||||
}
|
||||
@@ -1448,7 +1507,7 @@ impl Streaming {
|
||||
/// in `save(true)`, so older generation cannot overwrite the final chunks.
|
||||
pub fn poll_save(&mut self, world: &mut World) -> bool {
|
||||
let started = std::time::Instant::now();
|
||||
while let Ok(done) = self.results.try_recv() {
|
||||
while let Some(done) = self.shared.done.try_recv() {
|
||||
match done {
|
||||
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
|
||||
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
||||
@@ -1533,7 +1592,7 @@ impl Streaming {
|
||||
/// Wait up to `timeout` for a worker to finish a job; the next `update`
|
||||
/// takes it. A server pumps generation with this between its ticks.
|
||||
pub fn wait(&mut self, timeout: std::time::Duration) {
|
||||
if let Ok(done) = self.results.recv_timeout(timeout) {
|
||||
if let Some(done) = self.shared.done.recv_timeout(timeout) {
|
||||
self.deferred.push(done);
|
||||
}
|
||||
}
|
||||
@@ -1814,10 +1873,10 @@ impl Drop for Streaming {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while self.saving > 0 {
|
||||
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
match self.results.recv_timeout(left) {
|
||||
Ok(Done::Saved(_)) => self.saving -= 1,
|
||||
Ok(_) => {}
|
||||
Err(_) => break,
|
||||
match self.shared.done.recv_timeout(left) {
|
||||
Some(Done::Saved(_)) => self.saving -= 1,
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
self.shared.stop.store(true, Ordering::Relaxed);
|
||||
@@ -1855,6 +1914,37 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The frame never waits for a worker: while one holds the mailbox, the
|
||||
/// frame takes nothing and finds the results, in order, the next time.
|
||||
#[test]
|
||||
fn the_frame_takes_finished_jobs_without_waiting() {
|
||||
let mailbox = Mailbox::default();
|
||||
let lit = |seq| Done::Lit(0, 0, Vec::new(), seq);
|
||||
mailbox.send(lit(1));
|
||||
mailbox.send(lit(2));
|
||||
let sending = mailbox.done.lock().unwrap();
|
||||
assert!(mailbox.take().is_empty());
|
||||
assert!(mailbox.try_recv().is_none());
|
||||
drop(sending);
|
||||
let seqs: Vec<u64> = mailbox
|
||||
.take()
|
||||
.into_iter()
|
||||
.map(|done| match done {
|
||||
Done::Lit(_, _, _, seq) => seq,
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(seqs, [1, 2]);
|
||||
assert!(mailbox.take().is_empty());
|
||||
let waiting = std::time::Instant::now();
|
||||
assert!(
|
||||
mailbox
|
||||
.recv_timeout(std::time::Duration::from_millis(10))
|
||||
.is_none()
|
||||
);
|
||||
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
|
||||
let forced = HashSet::new();
|
||||
|
||||
@@ -14,11 +14,13 @@ use zip::{CompressionMethod, ZipArchive};
|
||||
/// bomb stops here.
|
||||
const MAX_ENTRY: usize = 1 << 30;
|
||||
|
||||
/// One archive with its entries indexed by name (`\` read as `/`, folders
|
||||
/// left out).
|
||||
/// One archive with its entries indexed by name (`\` read as `/`), files
|
||||
/// and folders apart.
|
||||
pub struct Zip {
|
||||
archive: ZipArchive<Cursor<Arc<[u8]>>>,
|
||||
entries: BTreeMap<String, usize>,
|
||||
/// Folder entries (`name/`) by their name without the slash.
|
||||
folders: BTreeMap<String, usize>,
|
||||
/// The archive's length: no size an entry claims can be bigger.
|
||||
len: usize,
|
||||
}
|
||||
@@ -33,26 +35,44 @@ impl Zip {
|
||||
normalize_end(&mut data);
|
||||
let len = data.len();
|
||||
let archive = ZipArchive::new(Cursor::new(Arc::from(data))).map_err(invalid)?;
|
||||
let entries = (0..archive.len())
|
||||
.filter_map(|i| Some((archive.name_for_index(i)?.replace('\\', "/"), i)))
|
||||
.filter(|(name, _)| !name.ends_with('/'))
|
||||
.collect();
|
||||
let (mut entries, mut folders) = (BTreeMap::new(), BTreeMap::new());
|
||||
for i in 0..archive.len() {
|
||||
let Some(name) = archive.name_for_index(i) else {
|
||||
continue;
|
||||
};
|
||||
let name = name.replace('\\', "/");
|
||||
match name.strip_suffix('/') {
|
||||
Some(folder) => folders.insert(folder.to_owned(), i),
|
||||
None => entries.insert(name, i),
|
||||
};
|
||||
}
|
||||
Ok(Self {
|
||||
archive,
|
||||
entries,
|
||||
folders,
|
||||
len,
|
||||
})
|
||||
}
|
||||
/// The files, as `FilePackResources.listResources` lists them (folder
|
||||
/// entries skipped).
|
||||
pub fn names(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
}
|
||||
/// `FilePackResources.getResource` through `ZipFile.getEntry`: the file
|
||||
/// `name`, else the folder entry `name/`, whose bytes Java reads like a
|
||||
/// file's (Loka's pack stores its `pack.mcmeta` and `pack.png` that way).
|
||||
fn index(&self, name: &str) -> Option<usize> {
|
||||
self.entries
|
||||
.get(name)
|
||||
.or_else(|| self.folders.get(name))
|
||||
.copied()
|
||||
}
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.entries.contains_key(name)
|
||||
self.index(name).is_some()
|
||||
}
|
||||
pub fn read(&self, name: &str) -> io::Result<Vec<u8>> {
|
||||
let &index = self
|
||||
.entries
|
||||
.get(name)
|
||||
let index = self
|
||||
.index(name)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, name.to_owned()))?;
|
||||
// A clone shares the bytes and the central directory: reading needs `&mut`.
|
||||
let mut archive = self.archive.clone();
|
||||
@@ -133,13 +153,16 @@ mod tests {
|
||||
use super::*;
|
||||
/// A hand-built archive with one stored and one deflated entry.
|
||||
fn archive() -> Vec<u8> {
|
||||
let files: [(&str, &[u8], bool); 2] = [
|
||||
archive_of(&[
|
||||
("pack.mcmeta", b"{\"pack\":{}}", false),
|
||||
("a/b.txt", b"hello hello hello hello", true),
|
||||
];
|
||||
])
|
||||
}
|
||||
/// A hand-built archive of `(name, body, deflated)` entries.
|
||||
fn archive_of(files: &[(&str, &[u8], bool)]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut central = Vec::new();
|
||||
for (name, body, deflate) in files {
|
||||
for &(name, body, deflate) in files {
|
||||
let data = if deflate {
|
||||
miniz_oxide::deflate::compress_to_vec(body, 6)
|
||||
} else {
|
||||
@@ -174,8 +197,8 @@ mod tests {
|
||||
out.extend(¢ral);
|
||||
out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
out.extend([0; 4]);
|
||||
out.extend(2u16.to_le_bytes());
|
||||
out.extend(2u16.to_le_bytes());
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((files.len() as u16).to_le_bytes());
|
||||
out.extend((central.len() as u32).to_le_bytes());
|
||||
out.extend(start.to_le_bytes());
|
||||
out.extend([0; 2]);
|
||||
@@ -191,6 +214,29 @@ mod tests {
|
||||
assert!(zip.read("missing").is_err());
|
||||
}
|
||||
|
||||
/// Loka's server pack (#3413) names its metadata `pack.mcmeta/`.
|
||||
/// `ZipFile.getEntry("pack.mcmeta")` falls back to that folder entry and
|
||||
/// vanilla reads its bytes; a file of the exact name still comes first.
|
||||
#[test]
|
||||
fn a_name_falls_back_to_its_folder_entry() {
|
||||
let zip = Zip::from_bytes(archive_of(&[
|
||||
("pack.mcmeta/", b"{\"pack\":{\"pack_format\":75}}", true),
|
||||
("pack.png/", b"png", false),
|
||||
("a/", b"folder", false),
|
||||
("a", b"file", false),
|
||||
]))
|
||||
.unwrap();
|
||||
assert_eq!(zip.names().collect::<Vec<_>>(), ["a"]);
|
||||
assert!(zip.contains("pack.mcmeta"));
|
||||
assert_eq!(
|
||||
zip.read("pack.mcmeta").unwrap(),
|
||||
b"{\"pack\":{\"pack_format\":75}}"
|
||||
);
|
||||
assert_eq!(zip.read("pack.png").unwrap(), b"png");
|
||||
assert_eq!(zip.read("a").unwrap(), b"file");
|
||||
assert!(!zip.contains("pack"));
|
||||
}
|
||||
|
||||
/// A pack as zip tools write it: folder entries, a nested pack root,
|
||||
/// stored and deflated files, and a Windows-style name.
|
||||
fn written() -> Vec<u8> {
|
||||
|
||||
@@ -91,9 +91,6 @@ default = ["audio", "controllers"]
|
||||
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", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
|
||||
# (release.yml); the tests and probes don't need it. Content mods load without it.
|
||||
mods = ["horizon-mods/runtime"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Геймпад",
|
||||
"horizon.options.vibration": "Вібрація",
|
||||
"horizon.options.adaptive_triggers": "Адаптивні тригери",
|
||||
"horizon.options.lightbar": "Світлова панель",
|
||||
"horizon.options.look_sensitivity": "Чутливість огляду",
|
||||
"horizon.options.invert_x": "Інвертувати огляд по X",
|
||||
"horizon.options.invert_y": "Інвертувати огляд по Y",
|
||||
"horizon.options.sneak_button": "Кнопка присідання",
|
||||
"horizon.options.move_deadzone": "Мертва зона руху",
|
||||
"horizon.options.look_deadzone": "Мертва зона огляду",
|
||||
"horizon.options.button_glyphs": "Значки кнопок",
|
||||
"horizon.options.menu_navigation": "Навігація в меню",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура"
|
||||
}
|
||||
@@ -88,6 +88,7 @@ mod sound_event_fixture;
|
||||
mod spawn_rules_fixture;
|
||||
mod sprint_probe;
|
||||
mod squid_client_probe;
|
||||
mod storage_menu_fixture;
|
||||
mod subtitle_fixture;
|
||||
mod tab_list_fixture;
|
||||
mod team_rules_fixture;
|
||||
@@ -179,6 +180,7 @@ probes! {
|
||||
sound_event_fixture,
|
||||
spawn_rules_fixture,
|
||||
sprint_probe,
|
||||
storage_menu_fixture,
|
||||
subtitle_fixture,
|
||||
tab_list_fixture,
|
||||
team_rules_fixture,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Storage block menus for tools/test_storage_menu_parity.py: reads one
|
||||
//! block id per line and prints the menu type the server opens it with
|
||||
//! (`horizon_client::containers::block_menu_type`, as `lan_open_container`
|
||||
//! sends it) and whether that menu's slots take a shulker box: the server
|
||||
//! refuses them in a `minecraft:shulker_box` menu (`ShulkerBoxSlot.mayPlace`,
|
||||
//! `lan::containers`).
|
||||
//!
|
||||
//! usage: storage_menu_fixture INPUT OUTPUT
|
||||
use horizon_client::containers::block_menu_type;
|
||||
use std::fs;
|
||||
|
||||
fn case(block: &str) -> Option<String> {
|
||||
let menu = block_menu_type(block.trim())?;
|
||||
let accepts = if menu == "minecraft:shulker_box" {
|
||||
"refuses"
|
||||
} else {
|
||||
"accepts"
|
||||
};
|
||||
Some(format!("{menu} {accepts} shulker_box"))
|
||||
}
|
||||
|
||||
pub fn main() -> horizon_client::Result<()> {
|
||||
let args: Vec<String> = crate::args().collect();
|
||||
let input = fs::read_to_string(
|
||||
args.get(1)
|
||||
.ok_or("usage: storage_menu_fixture INPUT OUTPUT")?,
|
||||
)?;
|
||||
let lines: Vec<String> = input
|
||||
.lines()
|
||||
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
|
||||
.collect();
|
||||
fs::write(
|
||||
args.get(2).ok_or("missing OUTPUT")?,
|
||||
lines.join("\n") + "\n",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -146,6 +146,7 @@ const PAGES: Ty = Ty::Enum(&[
|
||||
"shader-packs",
|
||||
"graphics",
|
||||
"graphics-advanced",
|
||||
"graphics-hdr",
|
||||
"extras",
|
||||
"horizon",
|
||||
"agent",
|
||||
@@ -425,7 +426,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
|
||||
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
|
||||
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
|
||||
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
|
||||
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
|
||||
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
|
||||
|
||||
@@ -110,14 +110,9 @@ mod minecarts;
|
||||
mod mob_blocks;
|
||||
mod mob_gear;
|
||||
mod mob_sounds;
|
||||
mod mod_input;
|
||||
#[cfg(feature = "mods")]
|
||||
mod mod_panel;
|
||||
mod mod_render;
|
||||
mod mods;
|
||||
mod mount_menu;
|
||||
mod mounts;
|
||||
mod moved_extras;
|
||||
mod moving_pistons;
|
||||
mod music;
|
||||
mod names;
|
||||
@@ -170,7 +165,6 @@ mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_links;
|
||||
mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
@@ -326,15 +320,24 @@ struct Options {
|
||||
dedicated: Option<Vec<String>>,
|
||||
/// `--benchmark SCENARIO`: run the benchmark, write its report and quit.
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
|
||||
/// `--dev-mod PATH` (repeatable): content mods always on.
|
||||
dev_mods: Vec<PathBuf>,
|
||||
/// Single player runs on an integrated server thread (game/integrated.rs),
|
||||
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
|
||||
/// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead.
|
||||
/// DEPRECATED: never use it; it is unsupported and will be removed soon.
|
||||
server_thread: bool,
|
||||
/// What this Game executes; independent of its current server connection.
|
||||
execution_role: execution_role::ExecutionRole,
|
||||
}
|
||||
/// `--in-frame`, `--no-server-thread` and `HORIZON_SERVER_THREAD=0` are deprecated:
|
||||
/// the in-frame world is unmaintained, gets no fixes and will be removed soon.
|
||||
fn warn_in_frame_deprecated(what: &str) {
|
||||
eprintln!(
|
||||
"Horizon: DEPRECATED: {what} runs single player's world in the window, a path that is no longer supported, \
|
||||
gets no fixes and will be removed soon. Do not use it: single player runs on its server thread by default."
|
||||
);
|
||||
}
|
||||
impl Options {
|
||||
/// The name to join servers with: the one given, else the offline name kept
|
||||
/// next to options.txt (created on first use).
|
||||
@@ -386,7 +389,13 @@ impl Options {
|
||||
dedicated: None,
|
||||
benchmark: None,
|
||||
dev_mods: Vec::new(),
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0"),
|
||||
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| {
|
||||
let on = v != "0";
|
||||
if !on {
|
||||
warn_in_frame_deprecated("HORIZON_SERVER_THREAD=0");
|
||||
}
|
||||
on
|
||||
}),
|
||||
execution_role: execution_role::ExecutionRole::Client,
|
||||
};
|
||||
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
|
||||
@@ -395,7 +404,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -411,7 +420,10 @@ impl Options {
|
||||
"--offscreen" => o.offscreen = true,
|
||||
"--control" => o.control = Some(horizon_client::control::default_path()),
|
||||
"--server-thread" => o.server_thread = true,
|
||||
"--in-frame" | "--no-server-thread" => o.server_thread = false,
|
||||
"--in-frame" | "--no-server-thread" => {
|
||||
warn_in_frame_deprecated(&arg);
|
||||
o.server_thread = false;
|
||||
}
|
||||
// Horizon's own commands (/pregen), off by default as on vanilla.
|
||||
"--pregen" => horizon_client::command::vanilla::set_horizon_commands(true),
|
||||
_ => {
|
||||
@@ -423,8 +435,9 @@ impl Options {
|
||||
"--render-distance" | "--radius" => {
|
||||
o.distance = value.parse()?;
|
||||
o.distance_override = Some(o.distance);
|
||||
if !(2..=64).contains(&o.distance) {
|
||||
return Err("render distance must be 2..64".into());
|
||||
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
|
||||
if !(2..=max).contains(&o.distance) {
|
||||
return Err(format!("render distance must be 2..{max}").into());
|
||||
}
|
||||
}
|
||||
"--seed" => {
|
||||
@@ -535,9 +548,6 @@ enum Screen {
|
||||
DialogWarning,
|
||||
/// WinScreen: the end poem and the credits (game/win.rs).
|
||||
Win,
|
||||
/// A `ui` mod's own screen, or its settings page from Options > Mods
|
||||
/// (game/mods.rs).
|
||||
Mod,
|
||||
}
|
||||
struct Mining {
|
||||
pos: [i32; 3],
|
||||
@@ -1419,6 +1429,14 @@ fn select_creative_tab(
|
||||
clicks.skip_release();
|
||||
}
|
||||
|
||||
/// The icon the Select World list shows for the world in play
|
||||
/// (`GameRenderer.tryTakeScreenshotIfNeeded`): its path in the save, and when
|
||||
/// the last frame grab was asked for.
|
||||
struct WorldIcon {
|
||||
path: PathBuf,
|
||||
last_attempt: Option<Instant>,
|
||||
}
|
||||
|
||||
struct Game<'a> {
|
||||
/// Shared with an integrated server; `collision` and `motion` borrow from it.
|
||||
res: Arc<Resources>,
|
||||
@@ -1507,12 +1525,17 @@ struct Game<'a> {
|
||||
keys: HashSet<KeyCode>,
|
||||
// Physical input is separate from remapped and toggled movement keys.
|
||||
physical_keys: HashSet<KeyCode>,
|
||||
physical_buttons: HashSet<MouseButton>,
|
||||
left: bool,
|
||||
right: bool,
|
||||
cursor: (f32, f32),
|
||||
/// The cursor icon the window shows (`widgets::take_cursor` each frame).
|
||||
cursor_icon: horizon_client::widgets::Cursor,
|
||||
captured: bool,
|
||||
/// Only the local player owning keyboard and mouse changes the native pointer.
|
||||
keyboard_mouse: bool,
|
||||
/// This view's corner in its shared window; cursor warps include the offset.
|
||||
view_at: [u32; 2],
|
||||
focused: bool,
|
||||
/// When the window lost focus, while it is still without
|
||||
/// (`Minecraft.lastActiveTime`: pausing waits 500 ms).
|
||||
@@ -1919,6 +1942,9 @@ struct Game<'a> {
|
||||
/// The world was saved for the application ending (`save_for_exit`).
|
||||
saved_for_exit: bool,
|
||||
pending_save: Option<saving::ClosingSave>,
|
||||
/// The world list's icon in progress (`GameRenderer.tryTakeScreenshotIfNeeded`):
|
||||
/// `None` without a local world, or once the save has its icon.png.
|
||||
world_icon: Option<WorldIcon>,
|
||||
/// The block a guest is acting on (game/lan_actions.rs).
|
||||
forced_hit: Option<Hit>,
|
||||
/// The part of a right click a guest's packet asks for (game/lan_actions.rs).
|
||||
@@ -4173,9 +4199,6 @@ impl Game<'_> {
|
||||
correct_tool,
|
||||
)
|
||||
});
|
||||
if removed {
|
||||
self.mod_block_event(pos, broken, false);
|
||||
}
|
||||
if removed && !creative {
|
||||
let held = self.held().cloned();
|
||||
self.mine_block_with_held(&facts.name, facts.hardness);
|
||||
@@ -4638,7 +4661,6 @@ impl Game<'_> {
|
||||
};
|
||||
let target = [pos.x, pos.y, pos.z];
|
||||
let state = self.apply_item_block_state(target, state, &stack)?;
|
||||
self.mod_block_event(target, state, true);
|
||||
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
|
||||
let key = (target[0], target[1], target[2]);
|
||||
self.world.block_entities.insert(key, entity);
|
||||
@@ -5280,9 +5302,8 @@ impl Game<'_> {
|
||||
self.tick_totem();
|
||||
self.update_totem_ready();
|
||||
self.tick_command_extras();
|
||||
self.tick_mods();
|
||||
self.native_tick();
|
||||
self.tick_server_mods();
|
||||
self.tell_native_now();
|
||||
let camera_eye = self.camera_eye_height();
|
||||
let stick = self.pad_move();
|
||||
if self.riding.is_some() && self.player.is_some() {
|
||||
@@ -5304,6 +5325,7 @@ impl Game<'_> {
|
||||
let riding = self.riding.is_some();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let spin_targets = self.spin_targets();
|
||||
let sprint_window = self.settings.sprint_window;
|
||||
self.sync_player_body();
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
// A dedicated server has no local player: the world still runs.
|
||||
@@ -5356,7 +5378,7 @@ impl Game<'_> {
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.controlled_simulation_tick(keys)?;
|
||||
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
|
||||
// Player.jumpFromGround: the jump statistic.
|
||||
if grounded && keys.jump && !player.flying && player.velocity.y > 0.2 {
|
||||
self.stats.custom("jump", 1);
|
||||
@@ -5912,13 +5934,21 @@ impl Game<'_> {
|
||||
}
|
||||
// The real pointer takes over from an agent's.
|
||||
self.agent_pointer = false;
|
||||
// Splitscreen: another player has the pointer; this one's menus leave it alone.
|
||||
if !self.keyboard_mouse {
|
||||
self.captured = false;
|
||||
return;
|
||||
}
|
||||
let Some(window) = &self.window else { return };
|
||||
let size = self.renderer.as_ref().map_or_else(
|
||||
|| window.inner_size().into(),
|
||||
|r| [r.config.width, r.config.height],
|
||||
);
|
||||
let centre =
|
||||
winit::dpi::PhysicalPosition::new(f64::from(size[0]) / 2.0, f64::from(size[1]) / 2.0);
|
||||
let [vx, vy] = self.view_at.map(f64::from);
|
||||
let centre = winit::dpi::PhysicalPosition::new(
|
||||
vx + f64::from(size[0]) / 2.0,
|
||||
vy + f64::from(size[1]) / 2.0,
|
||||
);
|
||||
let warp = |window: &Window| {
|
||||
let _ = window.set_cursor_position(centre);
|
||||
};
|
||||
@@ -5984,7 +6014,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
Cursor::Arrow
|
||||
};
|
||||
if effective == self.cursor_icon {
|
||||
if effective == self.cursor_icon || !self.keyboard_mouse {
|
||||
return;
|
||||
}
|
||||
if let Some(window) = &self.window {
|
||||
@@ -6256,7 +6286,6 @@ impl Game<'_> {
|
||||
// Hud: the action bar and titles draw over the hotbar (nextStratum).
|
||||
self.draw_boss_bars(&mut batch);
|
||||
self.draw_extras(&mut batch);
|
||||
self.draw_mod_shapes(&mut batch);
|
||||
}
|
||||
if show_hud {
|
||||
// Hud.nextContextualInfoState: the jump bar, the experience bar or
|
||||
@@ -6435,7 +6464,6 @@ impl Game<'_> {
|
||||
self.draw_creative(&mut batch);
|
||||
}
|
||||
Screen::Chat => self.draw_chat_screen(&mut batch),
|
||||
Screen::Mod => self.draw_mod_screen(&mut batch),
|
||||
Screen::Playing | Screen::Options | Screen::Win => {}
|
||||
}
|
||||
self.draw_saving_indicator(&mut batch);
|
||||
@@ -6929,6 +6957,9 @@ impl Game<'_> {
|
||||
self.inventory[i] = Some(stack);
|
||||
}
|
||||
DisplayClick::Offhand(stack) => self.offhand = Some(stack),
|
||||
// handleCreativeModeItemDrop: on a server the stack goes to it (SetCreativeModeSlot
|
||||
// to slot -1), which drops it; a local drop here would never reach the server.
|
||||
DisplayClick::Drop(stack) if self.remote.is_some() => self.remote_creative_throw(stack),
|
||||
DisplayClick::Drop(stack) => self.player_drop(stack),
|
||||
_ => {}
|
||||
}
|
||||
@@ -9338,6 +9369,12 @@ impl Game<'_> {
|
||||
book.to_be_displayed.retain(|r| index.contains(r));
|
||||
self.dim = dim;
|
||||
self.world_root = save_root.clone();
|
||||
// LevelStorageAccess.getIconFile: this save's icon.png is what the
|
||||
// Select World list shows.
|
||||
self.world_icon = Some(WorldIcon {
|
||||
path: save_root.join("icon.png"),
|
||||
last_attempt: None,
|
||||
});
|
||||
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
|
||||
self.block_ticks = Ticks::new(self.level.seed as i32);
|
||||
self.tick_chunks.clear();
|
||||
@@ -9664,10 +9701,12 @@ impl Game<'_> {
|
||||
self.music.stop_playing(None, &mut self.audio);
|
||||
self.audio.stop_all();
|
||||
if self.remote.is_some() {
|
||||
self.world_icon = None;
|
||||
return self.leave_server(None);
|
||||
}
|
||||
// The save screen has finished every write before releasing storage.
|
||||
self.session_lock = None;
|
||||
self.world_icon = None;
|
||||
horizon_client::crash::clear_detail("World");
|
||||
self.debug_charts.reset();
|
||||
self.boss_overlay.reset();
|
||||
@@ -9693,6 +9732,7 @@ impl Game<'_> {
|
||||
));
|
||||
if let Some(title) = &mut self.title {
|
||||
title.last_server = self.settings.file.string("lastServer");
|
||||
title.skip_multiplayer_warning = self.settings.file.bool("skipMultiplayerWarning");
|
||||
}
|
||||
self.capture(false);
|
||||
Ok(())
|
||||
@@ -10674,10 +10714,13 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1));
|
||||
let weapon = self.held().cloned();
|
||||
let (power_bonus, infinity) = self.bow_enchantments(&ammo.item);
|
||||
let effect = dispenser::tipped_effect(&ammo).or_else(|| {
|
||||
(&*ammo.item == "minecraft:spectral_arrow").then_some(("minecraft:glowing", 200))
|
||||
});
|
||||
let kind = horizon_client::projectiles::ProjectileKind::Arrow {
|
||||
base_damage: 2.0 + power_bonus,
|
||||
critical: power >= 1.0,
|
||||
effect: None,
|
||||
effect,
|
||||
};
|
||||
let mut arrow = horizon_client::projectiles::Projectile::new(
|
||||
kind,
|
||||
@@ -10822,14 +10865,16 @@ impl Game<'_> {
|
||||
// The main hand on its arm, the off hand on the other one.
|
||||
let left = self.settings.main_hand_left != (hand == Hand::Off);
|
||||
let Some(stack) = item else {
|
||||
// Only the main hand's empty arm shows.
|
||||
if hand == Hand::Off {
|
||||
// renderArmWithItem: only the main hand's empty arm shows,
|
||||
// on the main arm (the left one with the main hand set to
|
||||
// left), and not while the player is invisible.
|
||||
if hand == Hand::Off || self.potions.effects.get("invisibility").is_some() {
|
||||
continue;
|
||||
}
|
||||
// renderPlayerArm (right arm)
|
||||
// renderPlayerArm
|
||||
let m = to_world
|
||||
* horizon_client::first_person::player_arm_pose(false, inverse_height, attack);
|
||||
self.first_person_arm(false, m, packed, &mut out);
|
||||
* horizon_client::first_person::player_arm_pose(left, inverse_height, attack);
|
||||
self.first_person_arm(left, m, packed, &mut out);
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = maps::map_id(&stack) {
|
||||
@@ -11001,6 +11046,9 @@ impl Game<'_> {
|
||||
self.all_sections_changed();
|
||||
}
|
||||
self.audio.master = self.settings.master_volume;
|
||||
if self.settings.sound_device != self.audio.device() {
|
||||
let _ = self.audio.set_device(&self.settings.sound_device);
|
||||
}
|
||||
horizon_client::hud::GUI_SCALE.store(
|
||||
self.settings.gui_scale,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
@@ -11591,7 +11639,10 @@ impl Game<'_> {
|
||||
self.draw_carried(batch);
|
||||
if tab.inventory {
|
||||
if Self::creative_trash_hovered(batch, self.cursor) {
|
||||
self.tooltip(batch, "Destroy Item", self.cursor.0, self.cursor.1);
|
||||
// `CreativeModeInventoryScreen`: the destroy slot's tooltip is
|
||||
// `inventory.binSlot`, not an English literal.
|
||||
let destroy = self.translate("inventory.binSlot", "Destroy Item");
|
||||
self.tooltip(batch, &destroy, self.cursor.0, self.cursor.1);
|
||||
} else if self.carried.is_none()
|
||||
&& let Some(slot) = self.slot_at(batch, self.cursor.0, self.cursor.1)
|
||||
&& let Some(stack) = self.slot_view(slot)
|
||||
@@ -11826,7 +11877,59 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `GameRenderer.tryTakeScreenshotIfNeeded`: while a local world without a
|
||||
/// saved icon is on the screen, grab the rendered frame once a second until
|
||||
/// the save has one. The Select World list shows it as the world's preview.
|
||||
fn tick_world_icon(&mut self) {
|
||||
let Some(icon) = self.world_icon.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if icon
|
||||
.last_attempt
|
||||
.is_some_and(|at| at.elapsed() < Duration::from_secs(1))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if icon.path.is_file() {
|
||||
// tryTakeScreenshotIfNeeded's hasWorldScreenshot: it has one, stop asking.
|
||||
self.world_icon = None;
|
||||
return;
|
||||
}
|
||||
// takeAutoScreenshot only saves with every loaded section rendered, or
|
||||
// the icon would picture the holes the streamer has not filled yet.
|
||||
if self.streaming.pending() != 0
|
||||
|| !self.pending_uploads.is_empty()
|
||||
|| self.remote_backlog() != 0
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(renderer) = self.renderer.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// countRenderedSections() > 10: a client that joined its server
|
||||
// thread has nothing pending before the first chunks arrive, and the
|
||||
// icon would be only sky.
|
||||
if renderer.drawn_sections <= 10 {
|
||||
return;
|
||||
}
|
||||
if renderer.screenshot.is_some() {
|
||||
// F2's or an agent's screenshot owns the frame this time; ours
|
||||
// comes around again with the next attempt.
|
||||
return;
|
||||
}
|
||||
let path = icon.path.clone();
|
||||
if let Some(icon) = self.world_icon.as_mut() {
|
||||
icon.last_attempt = Some(Instant::now());
|
||||
}
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path,
|
||||
size: Some([64, 64]),
|
||||
});
|
||||
}
|
||||
fn render(&mut self) -> Result<bool> {
|
||||
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts. Its chunk map and progress are the server's, which
|
||||
// the window doesn't see yet: the title alone, over the menu background.
|
||||
@@ -11919,7 +12022,6 @@ impl Game<'_> {
|
||||
self.pitch,
|
||||
),
|
||||
};
|
||||
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
|
||||
self.audio
|
||||
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
|
||||
let block = [
|
||||
@@ -12258,10 +12360,7 @@ impl Game<'_> {
|
||||
dynamic_lights: &self.dynamic_lights,
|
||||
lightning: horizon_client::gfx::lightning::Input {
|
||||
bolts: &bolts,
|
||||
uv: [
|
||||
self.gui.atlas.font.white().u0,
|
||||
self.gui.atlas.font.white().v0,
|
||||
],
|
||||
uv: horizon_client::gfx::lightning::white_uv(&self.gui.atlas.font.white()),
|
||||
},
|
||||
additive: &additive,
|
||||
see_through: &see_through,
|
||||
@@ -12436,9 +12535,6 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen == Screen::Mod && self.mod_screen_key(key) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.sort_key_press(key) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -12476,7 +12572,6 @@ impl Game<'_> {
|
||||
Screen::CommandBlock => self.close_command_block(),
|
||||
Screen::Chat => self.close_chat(),
|
||||
Screen::Win => self.close_win_screen()?,
|
||||
Screen::Mod => self.close_mod_screen(true),
|
||||
},
|
||||
KeyCode::Digit1
|
||||
| KeyCode::Digit2
|
||||
@@ -12606,10 +12701,6 @@ impl Game<'_> {
|
||||
}
|
||||
/// A mouse button while playing (window or control socket).
|
||||
fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
|
||||
// A mod's claimed item takes the use button (capability `input`).
|
||||
if self.mod_use(button, pressed) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.sleeping.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -12677,19 +12768,11 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// LOD Distance and Far Detail Reduced as chosen: the player's LOD
|
||||
/// Distance when it is on, else a mod's far terrain
|
||||
/// (`render.set-far-terrain`).
|
||||
/// LOD Distance and Far Detail Reduced as the player chose them.
|
||||
fn far_terrain_choice(&self) -> (u32, bool) {
|
||||
let g = &self.settings.horizon;
|
||||
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
|
||||
if g.lod_distance > 0 {
|
||||
return (g.lod_distance, reduced);
|
||||
}
|
||||
match self.mod_far_terrain() {
|
||||
Some(f) => (f.distance, reduced || f.reduced),
|
||||
None => (0, reduced),
|
||||
}
|
||||
(g.lod_distance, reduced)
|
||||
}
|
||||
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
|
||||
fn lod_distance(&self) -> u32 {
|
||||
@@ -12827,12 +12910,14 @@ impl Game<'_> {
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
let g = &self.settings.horizon;
|
||||
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
|
||||
// The meshes, once copied, are freed by a worker (`Streaming::free`).
|
||||
if !budgeted && self.pending_uploads.is_empty() {
|
||||
horizon_client::spans::time("upload", || {
|
||||
for (key, mesh) in uploads {
|
||||
renderer.upload(key, &mesh);
|
||||
for (key, mesh) in &uploads {
|
||||
renderer.upload(*key, mesh);
|
||||
}
|
||||
});
|
||||
self.streaming.free(uploads);
|
||||
} else {
|
||||
// Horizon's chunk-update and upload budgets: the nearest
|
||||
// sections go first, the rest wait for later frames; a newer
|
||||
@@ -12842,12 +12927,17 @@ impl Game<'_> {
|
||||
let fresh: horizon_client::fast_hash::FxHashSet<(i32, i32, i32)> =
|
||||
uploads.iter().map(|(k, _)| *k).collect();
|
||||
let chunks = &self.world.chunks;
|
||||
pending.retain(|(k, _)| !fresh.contains(k) && chunks.contains_key(&(k.0, k.2)));
|
||||
pending.extend(
|
||||
uploads
|
||||
.into_iter()
|
||||
.filter(|(k, _)| chunks.contains_key(&(k.0, k.2))),
|
||||
);
|
||||
let loaded = |k: &(i32, i32, i32)| chunks.contains_key(&(k.0, k.2));
|
||||
let mut spent: Vec<_> = pending
|
||||
.extract_if(.., |(k, _)| fresh.contains(k) || !loaded(k))
|
||||
.collect();
|
||||
for upload in uploads {
|
||||
if loaded(&upload.0) {
|
||||
pending.push(upload);
|
||||
} else {
|
||||
spent.push(upload);
|
||||
}
|
||||
}
|
||||
let centre = self.streaming.center;
|
||||
let far = |k: &(i32, i32, i32)| (k.0 - centre.0).pow(2) + (k.2 - centre.1).pow(2);
|
||||
pending.sort_unstable_by_key(|(k, _)| std::cmp::Reverse(far(k)));
|
||||
@@ -12871,9 +12961,11 @@ impl Game<'_> {
|
||||
}
|
||||
let (key, mesh) = pending.pop().expect("checked above");
|
||||
renderer.upload(key, &mesh);
|
||||
spent.push((key, mesh));
|
||||
count += 1;
|
||||
bytes += size;
|
||||
}
|
||||
self.streaming.free(spent);
|
||||
}
|
||||
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
|
||||
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
|
||||
@@ -12977,8 +13069,16 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
if let WindowEvent::MouseInput { state, button, .. } = &event {
|
||||
if *state == ElementState::Pressed {
|
||||
self.physical_buttons.insert(*button);
|
||||
} else {
|
||||
self.physical_buttons.remove(button);
|
||||
}
|
||||
}
|
||||
if let WindowEvent::Focused(false) = &event {
|
||||
self.physical_keys.clear();
|
||||
self.physical_buttons.clear();
|
||||
}
|
||||
if let WindowEvent::ModifiersChanged(m) = &event {
|
||||
self.modifiers = m.state();
|
||||
@@ -13181,7 +13281,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& self.sleeping.is_none()
|
||||
&& self.player.is_some()
|
||||
&& !self.native_wheel_event(d)
|
||||
&& !self.mod_wheel(d)
|
||||
&& let Some((wheel, (_, vertical_steps))) =
|
||||
self.mouse
|
||||
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
|
||||
@@ -13207,18 +13306,23 @@ impl ApplicationHandler for Game<'_> {
|
||||
if self.screen == Screen::Chat && d != 0.0 {
|
||||
self.chat_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::CommandBlock && d != 0.0 {
|
||||
self.command_block_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Options && d != 0.0 {
|
||||
self.options_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Mod && d != 0.0 {
|
||||
self.mod_screen_scroll(d);
|
||||
}
|
||||
if self.screen == Screen::Creative && d != 0.0 {
|
||||
// AbstractContainerScreen.mouseScrolled: a bundle under the
|
||||
// cursor takes the wheel before the screen's own scrolling.
|
||||
let bundle = bundles::takes_bundle_wheel(self.screen)
|
||||
&& d != 0.0
|
||||
&& self.bundle_scroll(scaled(raw_x), scaled(raw_y));
|
||||
if self.screen == Screen::Creative && d != 0.0 && !bundle {
|
||||
self.creative_screen.wheel(f64::from(d), &self.pack.names);
|
||||
}
|
||||
if self.screen == Screen::Inventory
|
||||
&& d != 0.0
|
||||
&& !self.bundle_scroll(scaled(raw_x), scaled(raw_y))
|
||||
&& !bundle
|
||||
&& !self.tweak_scroll(d, self.inventory_target().0)
|
||||
{
|
||||
self.station_scroll(d);
|
||||
@@ -13390,7 +13494,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
return Ok(());
|
||||
}
|
||||
self.extras_mouse(button, pressed);
|
||||
self.mod_mouse_key(button, pressed);
|
||||
if pressed
|
||||
&& self.settings.keys.get("key.smoothCamera")
|
||||
== horizon_client::keybinds::mouse_name(button)
|
||||
@@ -13493,7 +13596,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.stats_click(size);
|
||||
}
|
||||
Screen::Stats if button == MouseButton::Left => self.stats_release(),
|
||||
Screen::Mod if pressed => self.mod_screen_click(button),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -13690,7 +13792,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return Ok(());
|
||||
};
|
||||
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
|
||||
self.native_key_event(key, event.state == ElementState::Pressed, event.repeat);
|
||||
// key.smoothCamera toggles the cinematic camera.
|
||||
if self.screen == Screen::Playing
|
||||
@@ -13795,12 +13896,11 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
self.extras_frame(elapsed);
|
||||
self.mods_frame(elapsed);
|
||||
self.poll_moved_extras();
|
||||
self.extras_retry()?;
|
||||
self.apply_mouse_look();
|
||||
self.pad_look(elapsed.as_secs_f32());
|
||||
self.advance_flight();
|
||||
self.tick_world_icon();
|
||||
let ticked = Instant::now();
|
||||
let rendered = self.render()?;
|
||||
self.apply_cursor_icon();
|
||||
@@ -13854,6 +13954,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
let mut consumed = false;
|
||||
let finished = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
@@ -13862,8 +13963,9 @@ impl ApplicationHandler for Game<'_> {
|
||||
&& self.ui.screenshot_message.is_some()
|
||||
{
|
||||
self.finish_screenshot(result);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) = finished
|
||||
if let Some(result) = &finished
|
||||
&& let Some(reply) = self.pending_screenshot.take()
|
||||
{
|
||||
let _ = reply.send(
|
||||
@@ -13875,6 +13977,13 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
consumed = true;
|
||||
}
|
||||
// A result nobody asked for was the world icon's
|
||||
// (`tick_world_icon`): a failure tries again a second
|
||||
// later, as `tryTakeScreenshotIfNeeded` does.
|
||||
if !consumed && let Some(Err(error)) = finished {
|
||||
log::warn!("Horizon: couldn't save the world icon: {error}");
|
||||
}
|
||||
if rendered {
|
||||
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
|
||||
@@ -14137,11 +14246,14 @@ impl<'a> Game<'a> {
|
||||
},
|
||||
keys: HashSet::new(),
|
||||
physical_keys: HashSet::new(),
|
||||
physical_buttons: HashSet::new(),
|
||||
left: false,
|
||||
right: false,
|
||||
cursor: (0.0, 0.0),
|
||||
cursor_icon: horizon_client::widgets::Cursor::Arrow,
|
||||
captured: false,
|
||||
keyboard_mouse: true,
|
||||
view_at: [0, 0],
|
||||
focused: true,
|
||||
focus_lost: None,
|
||||
modifiers: winit::keyboard::ModifiersState::empty(),
|
||||
@@ -14393,6 +14505,7 @@ impl<'a> Game<'a> {
|
||||
text_selection: Default::default(),
|
||||
saved_for_exit: false,
|
||||
pending_save: None,
|
||||
world_icon: None,
|
||||
forced_hit: None,
|
||||
guest_use: None,
|
||||
lan_events: Vec::new(),
|
||||
@@ -15272,7 +15385,6 @@ fn start_game<'a>(
|
||||
// Benchmarks measure the game alone.
|
||||
if game.options.benchmark.is_none() {
|
||||
game.mods.dev = game.options.dev_mods.clone();
|
||||
game.start_moved_extras();
|
||||
game.start_mods();
|
||||
}
|
||||
game.start_agent_control()?;
|
||||
@@ -15351,6 +15463,7 @@ fn start_game<'a>(
|
||||
&game.options.resources,
|
||||
);
|
||||
title.last_server = game.settings.file.string("lastServer");
|
||||
title.skip_multiplayer_warning = game.settings.file.bool("skipMultiplayerWarning");
|
||||
game.title = Some(title);
|
||||
}
|
||||
}
|
||||
@@ -15422,6 +15535,8 @@ struct Loading {
|
||||
progress: Progress,
|
||||
receiver: Receiver<LoaderMsg>,
|
||||
loaded: Option<std::result::Result<Loaded, String>>,
|
||||
/// The loader's `Done` arrived: its channel closing afterwards is expected.
|
||||
delivered: bool,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
shown: bool,
|
||||
menu: startup_menu::StartupMenu,
|
||||
@@ -15522,6 +15637,12 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// The loop may run once more after exit(): keep the first error, the
|
||||
// one that ended loading.
|
||||
if self.error.is_some() {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
if horizon_threads::stop_requests() > 0 {
|
||||
// No world is open yet: nothing to save.
|
||||
event_loop.exit();
|
||||
@@ -15605,7 +15726,7 @@ impl Loading {
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::new(&options.resources)
|
||||
Audio::with_device(&options.resources, &self.settings.sound_device)
|
||||
};
|
||||
let pack_error = loaded.pack_error.or_else(|| self.pack_error.take());
|
||||
// The game borrows its shared resources for the process's lifetime,
|
||||
@@ -15723,6 +15844,22 @@ impl ApplicationHandler for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every launch reads the prepared game files (`horizon-prepare`'s output, whose
|
||||
/// manifest is `horizon-resources.json`): without them, say where they were
|
||||
/// looked for and how to get them, before a window or a loader starts.
|
||||
fn missing_resources(root: &std::path::Path) -> Option<String> {
|
||||
if root.join("horizon-resources.json").is_file() {
|
||||
return None;
|
||||
}
|
||||
let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
Some(format!(
|
||||
"Horizon's prepared game files are missing: no horizon-resources.json in {}\n\
|
||||
Start Horizon from the Horizon launcher, which downloads and prepares them, \
|
||||
or prepare them with: horizon-prepare --prepare (or pass --resources <folder>)",
|
||||
shown.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// The ordinary launch: load the resources on a worker while the window shows
|
||||
/// the loading screen, then swap in the game on the main thread.
|
||||
fn run_loading(
|
||||
@@ -15750,6 +15887,7 @@ fn run_loading(
|
||||
progress,
|
||||
receiver,
|
||||
loaded: None,
|
||||
delivered: false,
|
||||
handle: Some(loader),
|
||||
shown: false,
|
||||
menu,
|
||||
@@ -15819,6 +15957,9 @@ pub fn run() -> Result<()> {
|
||||
let started = Instant::now();
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
return Err(error.into());
|
||||
}
|
||||
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
|
||||
if options.dedicated.is_none() {
|
||||
@@ -15860,7 +16001,7 @@ pub fn run() -> Result<()> {
|
||||
let audio = if !options.execution_role.is_client() {
|
||||
Audio::silent(&options.resources)
|
||||
} else {
|
||||
Audio::new(&options.resources)
|
||||
Audio::with_device(&options.resources, &early_settings.sound_device)
|
||||
};
|
||||
let resources = loaded.resources;
|
||||
let game = start_game(
|
||||
@@ -15922,6 +16063,21 @@ fn reset_world_progress(adv: &mut advancements::AdvState, stats: &mut stats::Sta
|
||||
mod startup_cpu_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_folder_without_prepared_files_is_named_before_loading() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-unprepared-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let missing = missing_resources(&root.join(".local/horizon-client/26.3"))
|
||||
.expect("no manifest is reported");
|
||||
assert!(missing.contains("horizon-resources.json"), "{missing}");
|
||||
assert!(missing.contains("horizon-unprepared-"), "{missing}");
|
||||
assert!(missing.contains("launcher"), "{missing}");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("horizon-resources.json"), "{}").unwrap();
|
||||
assert_eq!(missing_resources(&root), None);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_prepares_owned_models_and_inventory_before_handoff() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-startup-cpu-{}", std::process::id()));
|
||||
|
||||
@@ -424,7 +424,6 @@ impl Game<'_> {
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
|
||||
@@ -611,6 +610,9 @@ impl Game<'_> {
|
||||
json!({ "mode": "multiplayer", "disconnected": reason })
|
||||
}
|
||||
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
|
||||
Mode::MultiplayerWarning { check } => {
|
||||
json!({ "mode": "multiplayer-warning", "check": check })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1454,7 +1456,15 @@ impl Game<'_> {
|
||||
self.playing()?;
|
||||
let all = a.pos.first() == Some(&"all");
|
||||
let before = self.held().map(|s| self.stack_value(s));
|
||||
self.drop_held(all);
|
||||
if self.remote.is_some() {
|
||||
// As Q does on a server (and on the single-player server
|
||||
// thread): the server throws the stack, the client predicts it.
|
||||
if !self.spectator() {
|
||||
self.remote_drop(all);
|
||||
}
|
||||
} else {
|
||||
self.drop_held(all);
|
||||
}
|
||||
ok(
|
||||
json!({ "ok": true, "dropped_from": before, "held": self.held().map(|s| self.stack_value(s)) }),
|
||||
)
|
||||
|
||||
@@ -153,5 +153,6 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.update_spawner_display(key);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,15 @@ pub(super) struct BundleMouse {
|
||||
hovered: Option<usize>,
|
||||
}
|
||||
|
||||
/// Whether `screen` is an `AbstractContainerScreen`, whose `mouseScrolled` first offers
|
||||
/// the wheel to the hovered slot's `ItemSlotMouseAction`s (the bundle's), and whose
|
||||
/// `onStopHovering` clears the selection: the inventory and container menus, and
|
||||
/// `CreativeModeInventoryScreen`, which scrolls its items only when no bundle took
|
||||
/// the wheel.
|
||||
pub(super) fn takes_bundle_wheel(screen: Screen) -> bool {
|
||||
matches!(screen, Screen::Inventory | Screen::Creative)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
|
||||
/// container's own slots. A server only knows these in the inventory and chest-like menus.
|
||||
@@ -89,7 +98,7 @@ impl Game<'_> {
|
||||
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
|
||||
/// screen, clears the bundle's selection.
|
||||
pub(super) fn tick_bundle_hover(&mut self) {
|
||||
let now = if self.screen == Screen::Inventory {
|
||||
let now = if takes_bundle_wheel(self.screen) {
|
||||
self.inventory_target().0
|
||||
} else {
|
||||
None
|
||||
@@ -101,3 +110,24 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `CreativeModeInventoryScreen.mouseScrolled` calls `AbstractContainerScreen`'s
|
||||
/// first: a bundle under the cursor takes the wheel there too (#3211).
|
||||
#[test]
|
||||
fn container_screens_offer_the_wheel_to_bundles() {
|
||||
assert!(takes_bundle_wheel(Screen::Inventory));
|
||||
assert!(takes_bundle_wheel(Screen::Creative));
|
||||
for screen in [
|
||||
Screen::Playing,
|
||||
Screen::Chat,
|
||||
Screen::Paused,
|
||||
Screen::Options,
|
||||
] {
|
||||
assert!(!takes_bundle_wheel(screen));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,12 @@ use std::{
|
||||
|
||||
/// The tree the chat parses and completes with: a server's, once it has
|
||||
/// sent one (ClientPacketListener.commands), else the game's own.
|
||||
fn chat_tree<'a>(
|
||||
remote: &'a Option<super::remote::Remote>,
|
||||
mods: &'a super::mods::ModsState,
|
||||
) -> &'a horizon_client::command::Dispatcher {
|
||||
pub(super) fn chat_tree(
|
||||
remote: &Option<super::remote::Remote>,
|
||||
) -> &horizon_client::command::Dispatcher {
|
||||
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
|
||||
Some(tree) => mods.remote_dispatcher().unwrap_or(tree),
|
||||
None => mods.dispatcher(),
|
||||
Some(tree) => tree,
|
||||
None => horizon_client::command::vanilla::game_dispatcher(),
|
||||
}
|
||||
}
|
||||
use winit::{
|
||||
@@ -148,7 +147,7 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
recipes: ids_under(&data.join("recipe"), "json"),
|
||||
damage_types: ids_under(&data.join("damage_type"), "json"),
|
||||
loot_tables: ids_under(&data.join("loot_table"), "json"),
|
||||
templates: ids_under(&root.join("worldgen/structure/minecraft"), "nbt"),
|
||||
templates: templates_under(&root.join("worldgen/structure")),
|
||||
});
|
||||
cache.push((root.to_owned(), ids.clone()));
|
||||
ids
|
||||
@@ -156,6 +155,26 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
|
||||
|
||||
/// The `minecraft:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_under(root: &Path, extension: &str) -> Vec<String> {
|
||||
ids_in(root, extension, "minecraft")
|
||||
}
|
||||
|
||||
/// The templates of every namespace folder under `root` (vanilla's and the
|
||||
/// content mods'), as `namespace:path`, sorted.
|
||||
fn templates_under(root: &Path) -> Vec<String> {
|
||||
let Ok(namespaces) = std::fs::read_dir(root) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out: Vec<String> = namespaces
|
||||
.flatten()
|
||||
.filter(|e| e.path().is_dir())
|
||||
.flat_map(|e| ids_in(&e.path(), "nbt", &e.file_name().to_string_lossy()))
|
||||
.collect();
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// The `namespace:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_owned()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
@@ -175,7 +194,7 @@ fn ids_under(root: &Path, extension: &str) -> Vec<String> {
|
||||
&& let Ok(rel) = path.strip_prefix(root)
|
||||
{
|
||||
out.push(format!(
|
||||
"minecraft:{}",
|
||||
"{namespace}:{}",
|
||||
rel.with_extension("").to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
}
|
||||
@@ -459,7 +478,7 @@ impl Game<'_> {
|
||||
let b = HudBatch::new(size);
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let mut screen = ChatScreen::new(
|
||||
@@ -559,13 +578,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// A line on the window's own chat when the Chat option lets `source`
|
||||
/// through; running mods hear it.
|
||||
/// through.
|
||||
fn local_chat_line(&mut self, text: Text, indicator: Option<u32>, source: ChatSource) {
|
||||
if !self.ui.chat.shows(source) {
|
||||
return;
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.running().then(|| text.plain());
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
@@ -574,16 +592,13 @@ impl Game<'_> {
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
if let Some(line) = heard {
|
||||
self.mods_chat(&line);
|
||||
}
|
||||
}
|
||||
/// Keys while the chat screen is open.
|
||||
pub(super) fn chat_key(&mut self, event: &KeyEvent) {
|
||||
if let Some(shortcut) = self.edit_shortcut(event) {
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
@@ -629,7 +644,7 @@ impl Game<'_> {
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
|
||||
@@ -651,7 +666,7 @@ impl Game<'_> {
|
||||
ChatAction::None => {
|
||||
// charTyped: the text the key typed.
|
||||
if let Some(text) = typed_text
|
||||
&& !ctrl
|
||||
&& self.types_text()
|
||||
{
|
||||
screen.type_text(&m, &c, text);
|
||||
}
|
||||
@@ -669,7 +684,7 @@ impl Game<'_> {
|
||||
) {
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
@@ -680,7 +695,7 @@ impl Game<'_> {
|
||||
pub(super) fn chat_type(&mut self, text: &str) {
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if let Some(screen) = self.ui.chat_screen.as_mut() {
|
||||
@@ -707,8 +722,8 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if let Some(command) = message.strip_prefix('/') {
|
||||
// A client mod's command first, then a server mod's, as on a server.
|
||||
if !self.client_mod_command(command) && !self.local_server_mod_command(command) {
|
||||
// A native mod's command first.
|
||||
if !self.client_mod_command(command) {
|
||||
self.run_command(command);
|
||||
}
|
||||
} else {
|
||||
@@ -717,7 +732,6 @@ impl Game<'_> {
|
||||
&[self.player_display_name(), Text::literal(message)],
|
||||
);
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.running().then(|| line.plain());
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
line,
|
||||
@@ -726,11 +740,7 @@ impl Game<'_> {
|
||||
self.ticks,
|
||||
focused,
|
||||
);
|
||||
if let Some(line) = heard {
|
||||
self.mods_chat(&line);
|
||||
}
|
||||
self.lan_host_chat(message);
|
||||
self.mod_player_chat(super::server_mods::Who::Local, message);
|
||||
}
|
||||
}
|
||||
pub(super) fn chat_scroll(&mut self, amount: f32) {
|
||||
@@ -757,7 +767,7 @@ impl Game<'_> {
|
||||
});
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if self.ui.in_bed_chat && self.sleeping.is_some() {
|
||||
@@ -902,7 +912,7 @@ impl Game<'_> {
|
||||
}
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(&format!("/{command}"));
|
||||
} else if !self.local_server_mod_command(command) {
|
||||
} else {
|
||||
self.run_command(command);
|
||||
}
|
||||
}
|
||||
@@ -922,7 +932,7 @@ impl Game<'_> {
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: chat_tree(&self.remote, &self.mods),
|
||||
dispatcher: chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
screen.resize(&m, &c, batch.width as i32, batch.height as i32);
|
||||
@@ -1183,7 +1193,10 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let path = dir.join(name);
|
||||
renderer.screenshot = Some(path.clone());
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path);
|
||||
}
|
||||
/// After a frame: report a finished F2 screenshot in chat.
|
||||
|
||||
@@ -27,6 +27,22 @@ impl Game<'_> {
|
||||
m.alt_key() || held([KeyCode::AltLeft, KeyCode::AltRight]),
|
||||
)
|
||||
}
|
||||
/// Whether the held keys let a key's text through to the focused field
|
||||
/// (`clipboard::types_text`): not with the edit modifier, unless it is
|
||||
/// part of AltGr.
|
||||
pub(super) fn types_text(&self) -> bool {
|
||||
let held = |keys: [KeyCode; 2]| keys.iter().any(|k| self.physical_keys.contains(k));
|
||||
let edit = if cfg!(target_os = "macos") {
|
||||
held([KeyCode::SuperLeft, KeyCode::SuperRight])
|
||||
} else {
|
||||
held([KeyCode::ControlLeft, KeyCode::ControlRight])
|
||||
};
|
||||
clipboard::types_text(
|
||||
edit,
|
||||
held([KeyCode::AltLeft, KeyCode::AltRight]),
|
||||
clipboard::ALTGR_IS_CTRL_ALT,
|
||||
)
|
||||
}
|
||||
/// A shortcut in an append-only field limited to `max_chars` characters
|
||||
/// (an `EditBox` with `setMaxLength`). True when the value changed.
|
||||
pub(super) fn append_shortcut(
|
||||
|
||||
@@ -4,18 +4,21 @@
|
||||
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
|
||||
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
|
||||
use horizon_client::{
|
||||
chat::{ChatScreen, Commands},
|
||||
command_block::{self, CommandBlock, Mode},
|
||||
containers::BlockEntity,
|
||||
hud::{HudBatch, rgba},
|
||||
screen_draw::Measure,
|
||||
text::{self, Text},
|
||||
ui::{self, EditBox},
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::{
|
||||
chunk::BlockState,
|
||||
level::flags,
|
||||
nbt::Tag,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
use winit::{
|
||||
@@ -28,7 +31,10 @@ type Pos = (i32, i32, i32);
|
||||
/// The open `CommandBlockEditScreen`.
|
||||
pub(super) struct CommandBlockScreen {
|
||||
pos: Pos,
|
||||
command: EditBox,
|
||||
/// The command box with its `CommandSuggestions`.
|
||||
command: ChatScreen,
|
||||
/// When the command box was focused, for its cursor's blink.
|
||||
opened: std::time::Instant,
|
||||
previous: EditBox,
|
||||
track_output: bool,
|
||||
mode: Mode,
|
||||
@@ -93,6 +99,9 @@ impl CommandBlockScreen {
|
||||
Button::Cancel => tr("gui.cancel"),
|
||||
}
|
||||
}
|
||||
pub(super) fn pos(&self) -> Pos {
|
||||
self.pos
|
||||
}
|
||||
/// `updatePreviousOutput`.
|
||||
fn show_previous(&mut self, block: &CommandBlock) {
|
||||
let shown = if self.track_output {
|
||||
@@ -446,10 +455,14 @@ impl Game<'_> {
|
||||
let Some(state) = self.block_state_at(pos) else {
|
||||
return;
|
||||
};
|
||||
let mut command = EditBox::new(0.0, 50.0, 300.0, 20.0);
|
||||
command.max_length = 32500;
|
||||
command.set_value(&block.command);
|
||||
command.focused = true;
|
||||
let (w, h) = self.gui_size();
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
|
||||
command.auto_suggestions = self.settings.auto_suggestions;
|
||||
let mut previous = EditBox::new(0.0, 135.0, 276.0, 20.0);
|
||||
previous.max_length = 32500;
|
||||
previous.text_color = UNEDITABLE;
|
||||
@@ -457,6 +470,7 @@ impl Game<'_> {
|
||||
let mut screen = CommandBlockScreen {
|
||||
pos,
|
||||
command,
|
||||
opened: std::time::Instant::now(),
|
||||
previous,
|
||||
track_output: block.track_output,
|
||||
mode: Mode::of_block(&state.name).unwrap_or(Mode::Redstone),
|
||||
@@ -485,7 +499,14 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
let mid = (w / 2.0).floor();
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
// Screen.extractBackground of an isInGameUi screen: the dimmed world,
|
||||
// with no blur and no menu background.
|
||||
batch.fill_gradient(
|
||||
atlas,
|
||||
(0.0, 0.0, w, h),
|
||||
rgba(0x10, 0x10, 0x10, 0xC0),
|
||||
rgba(0x10, 0x10, 0x10, 0xD0),
|
||||
);
|
||||
let title = Text::translate("advMode.setCommand", &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(
|
||||
@@ -499,8 +520,6 @@ impl Game<'_> {
|
||||
let grey = rgba(160, 160, 160, 255);
|
||||
let label = Text::translate("advMode.command", &[]);
|
||||
batch.text_styled(atlas, &label, mid - 149.0, 40.0, grey, true);
|
||||
s.command.x = mid - 150.0;
|
||||
s.command.draw(batch, atlas, None);
|
||||
if !s.previous.value.is_empty() {
|
||||
let label = Text::translate("advMode.previousOutput", &[]);
|
||||
batch.text_styled(atlas, &label, mid - 149.0, 125.0, grey, true);
|
||||
@@ -511,12 +530,65 @@ impl Game<'_> {
|
||||
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
ui::button(batch, atlas, x, y, bw, 20.0, &s.label(b), true, hover);
|
||||
}
|
||||
// The command box, then its suggestions over the widgets.
|
||||
let m = Measure(&atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
s.command.resize(&m, &c, w as i32, h as i32);
|
||||
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
|
||||
let blink = s.opened.elapsed().as_millis() as u64;
|
||||
let draws = s.command.draw(&m, mouse, blink, 0);
|
||||
horizon_client::screen_draw::paint(batch, atlas, &draws);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
/// The wheel over the suggestion list (`CommandSuggestions.mouseScrolled`).
|
||||
pub(super) fn command_block_scroll(&mut self, amount: f32) {
|
||||
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
|
||||
if let Some(s) = self.command_block_screen.as_mut() {
|
||||
s.command.scroll(mouse, f64::from(amount));
|
||||
}
|
||||
}
|
||||
/// The command box's completion request for an `ask_server` node.
|
||||
pub(super) fn command_block_suggestion_request(&mut self) -> Option<(i32, String)> {
|
||||
self.command_block_screen.as_mut()?.command.take_request()
|
||||
}
|
||||
/// The server's completions for the command box's `ask_server` request.
|
||||
pub(super) fn command_block_server_suggestions(
|
||||
&mut self,
|
||||
id: i32,
|
||||
range: (usize, usize),
|
||||
list: Vec<(String, Option<String>)>,
|
||||
) {
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
s.command.server_suggestions(&m, &c, id, range, list);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
pub(super) fn command_block_click(&mut self) {
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
// CommandSuggestions.mouseClicked first, then the command box.
|
||||
{
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
let (x, y) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
|
||||
if s.command.click(&m, &c, x, y) {
|
||||
self.command_block_screen = Some(s);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -552,8 +624,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
/// `AbstractCommandBlockEditScreen.keyPressed`: Enter is Done, Escape
|
||||
/// cancels, the rest edits the command.
|
||||
/// `AbstractCommandBlockEditScreen.keyPressed`: the suggestions first,
|
||||
/// then Enter is Done, Escape cancels, the rest edits the command.
|
||||
pub(super) fn command_block_key(&mut self, event: &KeyEvent) {
|
||||
if event.state != ElementState::Pressed {
|
||||
return;
|
||||
@@ -561,27 +633,58 @@ impl Game<'_> {
|
||||
let PhysicalKey::Code(key) = event.physical_key else {
|
||||
return;
|
||||
};
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
self.command_block_key_code(key, shortcut, event.text.as_deref());
|
||||
}
|
||||
/// A key pressed on the command block screen, with the edit shortcut it
|
||||
/// makes and the text it types.
|
||||
pub(super) fn command_block_key_code(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
shortcut: Option<horizon_client::clipboard::EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// Windows' AltGr is Ctrl+Alt, and its characters still type.
|
||||
let types_text = self.types_text();
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
if s.command.suggestions_key(&m, &c, key, shift) {
|
||||
self.command_block_screen = Some(s);
|
||||
return;
|
||||
}
|
||||
let typed = match key {
|
||||
KeyCode::Escape | KeyCode::Enter | KeyCode::NumpadEnter => false,
|
||||
_ => {
|
||||
if let Some(shortcut) = shortcut {
|
||||
s.command.shortcut(&m, &c, shortcut, &mut self.clipboard);
|
||||
false
|
||||
} else {
|
||||
!s.command.field_key(&m, &c, key, shift, ctrl) && types_text
|
||||
}
|
||||
}
|
||||
};
|
||||
self.command_block_screen = Some(s);
|
||||
if typed {
|
||||
self.command_block_type(text.unwrap_or(""));
|
||||
}
|
||||
match key {
|
||||
KeyCode::Escape => return self.close_command_block(),
|
||||
KeyCode::Escape => self.close_command_block(),
|
||||
KeyCode::Enter | KeyCode::NumpadEnter => {
|
||||
if let Some(s) = self.command_block_screen.take() {
|
||||
self.set_command_block(&s);
|
||||
}
|
||||
return self.close_command_block();
|
||||
self.close_command_block();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let Some(s) = self.command_block_screen.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if let Some(shortcut) = shortcut {
|
||||
s.command.shortcut(shortcut, &mut self.clipboard);
|
||||
} else if !s.command.key(key, ctrl, shift) && !ctrl {
|
||||
self.command_block_type(event.text.as_deref().unwrap_or(""));
|
||||
}
|
||||
}
|
||||
/// Characters typed into the command box (`StringUtil.isAllowedChatCharacter`).
|
||||
pub(super) fn command_block_type(&mut self, typed: &str) {
|
||||
@@ -589,11 +692,19 @@ impl Game<'_> {
|
||||
.chars()
|
||||
.filter(|&c| c != '\u{a7}' && c >= ' ' && c != '\u{7f}')
|
||||
.collect();
|
||||
if let Some(s) = self.command_block_screen.as_mut()
|
||||
&& !allowed.is_empty()
|
||||
{
|
||||
s.command.insert_text(&allowed);
|
||||
if allowed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(mut s) = self.command_block_screen.take() else {
|
||||
return;
|
||||
};
|
||||
let m = Measure(&self.gui.atlas.font);
|
||||
let c = Commands {
|
||||
dispatcher: super::chat::chat_tree(&self.remote),
|
||||
registries: &self.ui.registries,
|
||||
};
|
||||
s.command.type_text(&m, &c, &allowed);
|
||||
self.command_block_screen = Some(s);
|
||||
}
|
||||
/// The edit screen's Done: a server's client sends the packet, the
|
||||
/// window's own world applies it (`handleSetCommandBlock`).
|
||||
@@ -604,7 +715,7 @@ impl Game<'_> {
|
||||
}
|
||||
let edit = CommandBlockEdit {
|
||||
pos: s.pos,
|
||||
command: s.command.value.clone(),
|
||||
command: s.command.value().to_owned(),
|
||||
track_output: s.track_output,
|
||||
mode: s.mode,
|
||||
conditional: s.conditional,
|
||||
@@ -653,6 +764,46 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.openCommandBlock`: the command block's data
|
||||
/// (`saveCustomOnly`) goes to the guest whose use opened it, as the
|
||||
/// chunk's update tag leaves it out and the edit screen shows it.
|
||||
pub(super) fn lan_open_command_block(&mut self, host: &mut LanHost, conn: u32, pos: Pos) {
|
||||
if !host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.is_some_and(|g| g.creative() && g.permission >= 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(block) = self.command_block(pos) else {
|
||||
return;
|
||||
};
|
||||
let tag = Tag::compound(block.save());
|
||||
let state = self.world.block(pos.0, pos.1, pos.2).0;
|
||||
let Some(kind) = host.hub.encoder.entity_type(state) else {
|
||||
return;
|
||||
};
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::BlockEntityData(play::BlockEntityData {
|
||||
pos: [pos.0, pos.1, pos.2],
|
||||
kind,
|
||||
tag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
/// `ClientPacketListener.handleBlockEntityData`: new data for the command
|
||||
/// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`).
|
||||
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
|
||||
let open = self.screen == Screen::CommandBlock
|
||||
&& self
|
||||
.command_block_screen
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.pos == pos);
|
||||
if open {
|
||||
self.open_command_block(pos);
|
||||
}
|
||||
}
|
||||
/// What `handleSetCommandBlock` does once the player may edit the block.
|
||||
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
|
||||
let pos = s.pos;
|
||||
|
||||
@@ -2069,6 +2069,7 @@ impl Game<'_> {
|
||||
return false;
|
||||
}
|
||||
self.world.block_entities.insert(key, new);
|
||||
self.update_spawner_display(key);
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ impl Game<'_> {
|
||||
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
|
||||
self.take_loot_table(key, &tag, &mut new);
|
||||
self.world.block_entities.insert(key, new);
|
||||
self.update_spawner_display(key);
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
// Redraw what shows the block entity (signs, banners).
|
||||
let _ = self.world.set(p[0], p[1], p[2], state);
|
||||
|
||||
@@ -246,9 +246,9 @@ impl Game<'_> {
|
||||
c.insert("z".into(), Tag::Int(at[2]));
|
||||
}
|
||||
if let Some((_, entity)) = save::decode_block_entity(&tag) {
|
||||
self.world
|
||||
.block_entities
|
||||
.insert((at[0], at[1], at[2]), entity);
|
||||
let key = (at[0], at[1], at[2]);
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.update_spawner_display(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const HELP: &[&str] = &[
|
||||
"tp X Y Z teleport the player",
|
||||
"hotbar 1-9 select a hotbar slot",
|
||||
"give ITEM [COUNT] add items to the inventory",
|
||||
"render-distance N chunks, 2..64",
|
||||
"render-distance N chunks, 2..128",
|
||||
"lod-distance N far surface chunks, 0 disables, up to1024",
|
||||
"time TICKS set the time of day",
|
||||
"summon KIND [X Y Z] [baby] spawn a mob (in front of the player without a position)",
|
||||
@@ -34,7 +34,7 @@ const HELP: &[&str] = &[
|
||||
"chat TEXT send a chat line or /command as if typed",
|
||||
"type TEXT type into the open chat box (key enter tap sends)",
|
||||
"chat-log [N] the newest N chat messages (default 10)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
|
||||
"stats [reset] frame-time summary since the last reset",
|
||||
"bench SECONDS reset stats, wait, then reply with the summary",
|
||||
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
|
||||
@@ -339,17 +339,17 @@ impl Game<'_> {
|
||||
self.debug_key(key, KeyAction::Release);
|
||||
}
|
||||
}
|
||||
// Mods' keys (capability `input`) see the key as the keyboard
|
||||
// hook does.
|
||||
// Native mods see the key as the keyboard hook does.
|
||||
let action = arg(2)?;
|
||||
self.mod_key(physical, action != "up", action == "repeat");
|
||||
self.native_key_event(physical, action != "up", action == "repeat");
|
||||
if action == "tap" {
|
||||
self.mod_key(physical, false, false);
|
||||
self.native_key_event(physical, false, false);
|
||||
}
|
||||
// Text screens consume raw presses; releases follow key binds.
|
||||
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
|
||||
let typing = matches!(
|
||||
self.screen,
|
||||
Screen::Chat | Screen::Options | Screen::CommandBlock
|
||||
);
|
||||
let key = if typing && arg(2)? != "up" {
|
||||
key
|
||||
} else {
|
||||
@@ -422,19 +422,15 @@ impl Game<'_> {
|
||||
match arg(2)? {
|
||||
"down" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.play_mouse(button, true)?
|
||||
}
|
||||
"up" => {
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, false)?
|
||||
}
|
||||
"click" => {
|
||||
self.extras_mouse(button, true);
|
||||
self.mod_mouse_key(button, true);
|
||||
self.extras_mouse(button, false);
|
||||
self.mod_mouse_key(button, false);
|
||||
self.play_mouse(button, true)?;
|
||||
self.play_mouse(button, false)?;
|
||||
}
|
||||
@@ -517,8 +513,9 @@ impl Game<'_> {
|
||||
}
|
||||
"render-distance" => {
|
||||
let n: i32 = arg(1)?.parse()?;
|
||||
if !(2..=64).contains(&n) {
|
||||
return Err("render distance must be 2..64".into());
|
||||
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
|
||||
if !(2..=max).contains(&n) {
|
||||
return Err(format!("render distance must be 2..{max}").into());
|
||||
}
|
||||
self.settings.render_distance = n;
|
||||
self.apply_settings();
|
||||
@@ -588,7 +585,10 @@ impl Game<'_> {
|
||||
arg(1)?;
|
||||
// The rest of the line: a path may hold spaces.
|
||||
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
|
||||
renderer.screenshot = Some(path.trim().into());
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
}
|
||||
@@ -678,6 +678,7 @@ impl Game<'_> {
|
||||
"shader-packs" => Page::ShaderPacks,
|
||||
"graphics" => Page::Graphics,
|
||||
"graphics-advanced" => Page::GraphicsAdvanced,
|
||||
"graphics-hdr" => Page::GraphicsHdr,
|
||||
"extras" => Page::Extras,
|
||||
"credits" => Page::Credits,
|
||||
"horizon" => Page::Horizon,
|
||||
@@ -855,6 +856,10 @@ impl Game<'_> {
|
||||
if self.screen == Screen::Options {
|
||||
return self.options_key(key, None);
|
||||
}
|
||||
if self.screen == Screen::CommandBlock {
|
||||
self.command_block_key_code(key, None, None);
|
||||
return Ok(());
|
||||
}
|
||||
if self.keys.insert(key) {
|
||||
self.key_pressed(key)?;
|
||||
}
|
||||
@@ -886,7 +891,6 @@ impl Game<'_> {
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
Screen::CommandBlock => "command-block",
|
||||
Screen::Win => "win",
|
||||
Screen::Mod => "mod",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ const FIREWORK_ROCKET: &str = "minecraft:firework_rocket";
|
||||
|
||||
/// `ProjectileWeaponItem.ARROW_ONLY`, for the arrows Horizon shoots.
|
||||
fn is_arrow(stack: &ItemStack) -> bool {
|
||||
&*stack.item == "minecraft:arrow"
|
||||
matches!(
|
||||
&*stack.item,
|
||||
"minecraft:arrow" | "minecraft:spectral_arrow" | "minecraft:tipped_arrow"
|
||||
)
|
||||
}
|
||||
|
||||
/// `Player.getProjectile`: what `weapon` supports in the off hand, then in
|
||||
@@ -206,10 +209,14 @@ impl Game<'_> {
|
||||
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
|
||||
}
|
||||
if !rocket {
|
||||
let effect = super::dispenser::tipped_effect(ammo).or_else(|| {
|
||||
(&*ammo.item == "minecraft:spectral_arrow")
|
||||
.then_some(("minecraft:glowing", 200))
|
||||
});
|
||||
let kind = ProjectileKind::Arrow {
|
||||
base_damage: 2.0,
|
||||
critical: true,
|
||||
effect: None,
|
||||
effect,
|
||||
};
|
||||
let mut arrow = Projectile::new(
|
||||
kind,
|
||||
@@ -277,5 +284,17 @@ mod tests {
|
||||
// The main hand's rocket, with the crossbow in the off hand.
|
||||
let hands = [(OFFHAND_SLOT, crossbow.as_ref()), (3, rocket.as_ref())];
|
||||
assert_eq!(projectile_slot(CROSSBOW, hands, &inventory), Some(3));
|
||||
|
||||
// Spectral and tipped arrows in off hand or inventory are recognized.
|
||||
let spectral = stack("minecraft:spectral_arrow");
|
||||
let tipped = stack("minecraft:tipped_arrow");
|
||||
inventory[7] = spectral.clone();
|
||||
assert_eq!(slot("minecraft:bow", &None, &inventory), Some(7));
|
||||
assert_eq!(
|
||||
slot("minecraft:bow", &tipped, &inventory),
|
||||
Some(OFFHAND_SLOT)
|
||||
);
|
||||
inventory[7] = None;
|
||||
assert_eq!(slot("minecraft:bow", &None, &inventory), None);
|
||||
}
|
||||
}
|
||||
@@ -2140,6 +2140,11 @@ pub(super) fn tipped_effect(stack: &ItemStack) -> Option<(&'static str, i32)> {
|
||||
def.effects
|
||||
.iter()
|
||||
.find(|(id, _, _)| !horizon_client::brewing::effect(id).is_some_and(|e| e.instant))
|
||||
.map(|(id, duration, _)| (*id, (duration / 8).max(1)))
|
||||
.map(|(id, duration, _)| {
|
||||
(
|
||||
horizon_client::brewing::namespaced(id),
|
||||
(duration / 8).max(1),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -2590,54 +2590,9 @@ impl Game<'_> {
|
||||
self.crystal_beam(pos, to - pos, self.ticks as f32 + a, camera, out);
|
||||
}
|
||||
}
|
||||
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
|
||||
/// `EnderDragonRenderer.submitRays`: untextured, additive death rays.
|
||||
fn dragon_rays(&self, base: glam::Mat4, t: f32, out: &mut Vec<Vertex>) {
|
||||
let white = self.gui.atlas.font.white();
|
||||
let uv = [white.u0, white.v0];
|
||||
let over = if t > 0.8 {
|
||||
((t - 0.8) / 0.2).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
let outer = 0x00FF_00FF;
|
||||
let mut random = LegacyRandom::new(432);
|
||||
let mut m = base;
|
||||
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
|
||||
let half_sqrt3 = 3f32.sqrt() / 2.0;
|
||||
let tau = std::f32::consts::TAU;
|
||||
for _ in 0..count {
|
||||
let mut f = || random.next_float() * tau;
|
||||
let q1 = glam::Quat::from_rotation_x(f())
|
||||
* glam::Quat::from_rotation_y(f())
|
||||
* glam::Quat::from_rotation_z(f());
|
||||
let (a, b, c) = (f(), f(), f());
|
||||
let q2 = glam::Quat::from_rotation_x(a)
|
||||
* glam::Quat::from_rotation_y(b)
|
||||
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
|
||||
m *= glam::Mat4::from_quat(q1 * q2);
|
||||
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
|
||||
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
|
||||
let o = glam::Vec3::ZERO;
|
||||
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
|
||||
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
|
||||
let bottom = glam::Vec3::new(0.0, length, width);
|
||||
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
|
||||
// A triangle as a quad with its last corner repeated.
|
||||
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(p).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
dragon_rays(base, t, self.gui.atlas.font.white(), out);
|
||||
}
|
||||
/// `submitCrystalBeams`: an eight-sided tube from `from` (+2 up) along
|
||||
/// `delta`, the beam texture scrolling along it.
|
||||
@@ -3597,10 +3552,93 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
|
||||
fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out: &mut Vec<Vertex>) {
|
||||
// Vanilla's dragonRays has no texture. Sample inside the white sprite:
|
||||
// its border can round into transparent neighbours during interpolation.
|
||||
let uv = [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0];
|
||||
let over = if t > 0.8 {
|
||||
((t - 0.8) / 0.2).min(1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
|
||||
let outer = 0x00FF_00FF;
|
||||
let mut random = LegacyRandom::new(432);
|
||||
let mut m = base;
|
||||
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
|
||||
let half_sqrt3 = 3f32.sqrt() / 2.0;
|
||||
let tau = std::f32::consts::TAU;
|
||||
for _ in 0..count {
|
||||
let mut f = || random.next_float() * tau;
|
||||
let q1 = glam::Quat::from_rotation_x(f())
|
||||
* glam::Quat::from_rotation_y(f())
|
||||
* glam::Quat::from_rotation_z(f());
|
||||
let (a, b, c) = (f(), f(), f());
|
||||
let q2 = glam::Quat::from_rotation_x(a)
|
||||
* glam::Quat::from_rotation_y(b)
|
||||
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
|
||||
m *= glam::Mat4::from_quat(q1 * q2);
|
||||
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
|
||||
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
|
||||
let o = glam::Vec3::ZERO;
|
||||
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
|
||||
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
|
||||
let bottom = glam::Vec3::new(0.0, length, width);
|
||||
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
|
||||
// A triangle as a quad with its last corner repeated.
|
||||
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: m.transform_point3(p).to_array(),
|
||||
uv,
|
||||
layer: 2,
|
||||
color: c,
|
||||
light: 0,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn death_rays_sample_inside_the_white_sprite() {
|
||||
// A four-texel white sprite surrounded by transparent atlas pixels.
|
||||
// A border UV can round outside it during perspective interpolation.
|
||||
let size = 1024.0;
|
||||
let white = horizon_client::hud::Rect {
|
||||
u0: 20.0 / size,
|
||||
v0: 60.0 / size,
|
||||
u1: 24.0 / size,
|
||||
v1: 64.0 / size,
|
||||
w: 4,
|
||||
h: 4,
|
||||
};
|
||||
for t in [0.25, 0.8, 0.95, 1.0] {
|
||||
let mut vertices = Vec::new();
|
||||
dragon_rays(glam::Mat4::IDENTITY, t, white, &mut vertices);
|
||||
assert!(!vertices.is_empty());
|
||||
for v in &vertices {
|
||||
assert!(
|
||||
v.uv[0] > white.u0 + 0.5 / size && v.uv[0] < white.u1 - 0.5 / size,
|
||||
"death-ray U touches the atlas border: {}",
|
||||
v.uv[0]
|
||||
);
|
||||
assert!(
|
||||
v.uv[1] > white.v0 + 0.5 / size && v.uv[1] < white.v1 - 0.5 / size,
|
||||
"death-ray V touches the atlas border: {}",
|
||||
v.uv[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dragon(phase: Phase) -> Dragon {
|
||||
let mut d = Dragon::new(Vec3::new(0.0, 70.0, 0.0), 0.0, 1);
|
||||
d.phase = phase;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Horizon's extras in the game (`docs/extras.md`); a mod's minimap
|
||||
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
|
||||
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
|
||||
//! Each hook does nothing while its extra is off and no mod runs.
|
||||
//! Horizon's extras in the game (`docs/extras.md`); a native mod's minimap
|
||||
//! (`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.
|
||||
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
|
||||
use horizon_client::{
|
||||
extras::{Corner, Cps, FpsCounter, fov_target},
|
||||
@@ -124,7 +124,7 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The minimap a mod shows (`render.set-minimap`): its corner, its side
|
||||
/// 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)> {
|
||||
let p = self.player.as_ref()?;
|
||||
@@ -142,20 +142,9 @@ impl Game<'_> {
|
||||
};
|
||||
Some((m.corner, m.size, view))
|
||||
}
|
||||
/// The minimap shown: a native mod's (`native_minimap`), else a
|
||||
/// WebAssembly mod's.
|
||||
/// The minimap shown: a native mod's (`native_minimap`).
|
||||
fn minimap_wanted(&self) -> Option<NativeMinimap> {
|
||||
if let Some(m) = self.native_minimap_now() {
|
||||
return Some(m);
|
||||
}
|
||||
let m = self.mod_minimap()?;
|
||||
Some(NativeMinimap {
|
||||
corner: m.corner,
|
||||
size: m.size,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
rotate: m.rotate,
|
||||
round: m.round,
|
||||
})
|
||||
self.native_minimap_now()
|
||||
}
|
||||
/// Refresh the minimap's tiles within the frame budget, recompose its
|
||||
/// image when the view or a tile changed, and queue the upload.
|
||||
@@ -209,13 +198,10 @@ impl Game<'_> {
|
||||
rgba: self.extras.minimap.image.clone(),
|
||||
});
|
||||
}
|
||||
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
|
||||
/// A mod's zoom in effect (`native_zoom`): (factor, smooth, turning
|
||||
/// scale); none is 1.
|
||||
fn zoom_now(&self) -> (f32, bool, f64) {
|
||||
if let Some(z) = self.native_zoom_now() {
|
||||
return (z.factor, z.smooth, f64::from(z.turn));
|
||||
}
|
||||
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
|
||||
self.native_zoom_now().map_or((1.0, true, 1.0), |z| {
|
||||
(z.factor, z.smooth, f64::from(z.turn))
|
||||
})
|
||||
}
|
||||
@@ -240,7 +226,6 @@ impl Game<'_> {
|
||||
/// The minimap and mods' corner panels, in the HUD layer (hidden with F1
|
||||
/// by the caller). The top left corner gives way to the debug screen.
|
||||
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
|
||||
let mod_panels = self.mods.has_panels();
|
||||
let map = self.minimap_shown();
|
||||
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
|
||||
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
|
||||
@@ -264,9 +249,6 @@ impl Game<'_> {
|
||||
};
|
||||
extras_hud::draw_minimap(batch, atlas, &mut corners, &look);
|
||||
}
|
||||
if mod_panels {
|
||||
self.draw_mod_panels(batch, &mut corners);
|
||||
}
|
||||
self.native_hud(batch, &mut corners);
|
||||
}
|
||||
/// The height the status effect icons take at the top right
|
||||
|
||||
@@ -132,6 +132,12 @@ impl Game<'_> {
|
||||
"minecraft:glow_item_frame" => true,
|
||||
_ => return false,
|
||||
};
|
||||
let invisible = stack.extra.as_ref().is_some_and(|extra| {
|
||||
extra.other.iter().any(|(k, v)| {
|
||||
(k == "minecraft:entity_data" || k == "entity_data")
|
||||
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
|
||||
})
|
||||
});
|
||||
let pos = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
|
||||
let frame = ItemFrame {
|
||||
id: self.next_entity + 1,
|
||||
@@ -142,7 +148,7 @@ impl Game<'_> {
|
||||
rotation: 0,
|
||||
drop_chance: 1.0,
|
||||
fixed: false,
|
||||
invisible: false,
|
||||
invisible,
|
||||
check_interval: 0,
|
||||
};
|
||||
if !self.frame_survives(&frame) {
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
//! into its `IntegratedServer`.
|
||||
//!
|
||||
//! On by default. `--in-frame` (or `--no-server-thread`, or
|
||||
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
|
||||
//! escape hatch. LAN guests and dedicated servers use the same code either
|
||||
//! way.
|
||||
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead. That path
|
||||
//! is DEPRECATED: never use it, it gets no fixes and will be removed soon.
|
||||
//! LAN guests and dedicated servers use the same code either way.
|
||||
use super::{
|
||||
Game, Options, PackResources, Resources, UiResources,
|
||||
Game, Options, PackResources, Resources, UiResources, WorldIcon,
|
||||
execution_role::ExecutionRole,
|
||||
lan::{LanSettings, Owner},
|
||||
world_loading::WorldRequest,
|
||||
@@ -177,6 +177,8 @@ pub(super) struct Starting {
|
||||
handle: Integrated,
|
||||
ready: Receiver<Ready>,
|
||||
player: String,
|
||||
/// The save's folder in `saves/`, for the world icon once joined.
|
||||
folder: String,
|
||||
}
|
||||
|
||||
/// The world to open and the player who owns it.
|
||||
@@ -287,6 +289,7 @@ impl Game<'_> {
|
||||
handle,
|
||||
ready,
|
||||
player,
|
||||
folder: folder.to_owned(),
|
||||
});
|
||||
// Harnesses, control scripts and runs without a window go on with the world open.
|
||||
if self.window.is_none() || super::synchronous_startup(&self.options) {
|
||||
@@ -318,13 +321,25 @@ impl Game<'_> {
|
||||
}
|
||||
};
|
||||
let Some(Starting {
|
||||
mut handle, player, ..
|
||||
mut handle,
|
||||
player,
|
||||
folder,
|
||||
..
|
||||
}) = self.integrated_starting.take()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
handle.world_name = world_name;
|
||||
self.connect_local(pipe, &player)?;
|
||||
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
|
||||
// the Select World list shows, and needs the save folder for it.
|
||||
self.world_icon = Some(WorldIcon {
|
||||
path: std::env::current_dir()?
|
||||
.join("saves")
|
||||
.join(&folder)
|
||||
.join("icon.png"),
|
||||
last_attempt: None,
|
||||
});
|
||||
self.integrated = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1608,9 +1608,7 @@ impl Game<'_> {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
self.lan_log_event(event);
|
||||
}
|
||||
HostEvent::PlayerChat { sender, text } => {
|
||||
self.mod_player_chat(super::server_mods::Who::Uuid(sender), &text);
|
||||
}
|
||||
HostEvent::PlayerChat { .. } => {}
|
||||
HostEvent::Joined(conn) => {
|
||||
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
|
||||
if self.options.dedicated.is_some()
|
||||
|
||||
@@ -626,7 +626,9 @@ impl Game<'_> {
|
||||
}) => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
self.command_block_screen = host_command_block;
|
||||
// A command block the guest's use opened (Player.openCommandBlock).
|
||||
let guest_command_block =
|
||||
std::mem::replace(&mut self.command_block_screen, host_command_block).map(|s| s.pos());
|
||||
// A mount inventory the guest's action opened (shift + use, or the
|
||||
// inventory key while riding).
|
||||
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
|
||||
@@ -675,9 +677,7 @@ impl Game<'_> {
|
||||
let new_mode = self.mode_id();
|
||||
self.mode = mode;
|
||||
self.creative = creative;
|
||||
self.selected = std::mem::replace(&mut g.selected, selected);
|
||||
// `g.selected` now holds the host's value: swap the inventories back.
|
||||
std::mem::swap(&mut self.selected, &mut g.selected);
|
||||
g.selected = std::mem::replace(&mut self.selected, selected);
|
||||
std::mem::swap(&mut self.inventory, &mut g.inventory);
|
||||
std::mem::swap(&mut self.armor, &mut g.armor);
|
||||
std::mem::swap(&mut self.offhand, &mut g.offhand);
|
||||
@@ -779,6 +779,9 @@ impl Game<'_> {
|
||||
if let Some(key) = guest_lectern {
|
||||
self.lan_open_lectern(host, conn, key);
|
||||
}
|
||||
if let Some(pos) = guest_command_block {
|
||||
self.lan_open_command_block(host, conn, pos);
|
||||
}
|
||||
if let Some(pos) = opened_table {
|
||||
self.lan_open_crafting(host, conn, pos);
|
||||
}
|
||||
@@ -892,6 +895,12 @@ impl Game<'_> {
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
// The block entity's createMenu: a shulker box opens a ShulkerBoxMenu
|
||||
// (not a 9x3 ChestMenu), whose slots refuse shulker boxes.
|
||||
let menu = match slots {
|
||||
Slots::Block(_) => horizon_client::containers::block_menu_type(name).unwrap_or(menu),
|
||||
_ => menu,
|
||||
};
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_container_closed(host, conn);
|
||||
self.lan_station_closed(host, conn, None);
|
||||
@@ -1892,10 +1901,6 @@ impl Game<'_> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A server mod's command, for every player whatever their level.
|
||||
if self.guest_server_mod_command(host, conn, command.trim_start_matches('/')) {
|
||||
return;
|
||||
}
|
||||
// An integrated server runs every player's commands at their own
|
||||
// permission level (`CommandSourceStack.permissions`); a LAN world
|
||||
// follows its Allow Commands, and a dedicated server leaves it to
|
||||
|
||||
@@ -154,8 +154,15 @@ impl Game<'_> {
|
||||
);
|
||||
e.head_yaw = a.yaw;
|
||||
e.velocity = [a.velocity.x, a.velocity.y, a.velocity.z];
|
||||
let mut flags = 0i8;
|
||||
if a.fire_ticks > 0 {
|
||||
e.meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
flags |= 1;
|
||||
}
|
||||
if a.invisible {
|
||||
flags |= 0x20;
|
||||
}
|
||||
if flags != 0 {
|
||||
e.meta.push((0, serializer::BYTE, Value::Byte(flags)));
|
||||
}
|
||||
// LivingEntity.DATA_HEALTH_ID: 1 until the stand's max health is set.
|
||||
e.meta.push((9, serializer::FLOAT, Value::Float(a.health)));
|
||||
@@ -222,6 +229,9 @@ impl Game<'_> {
|
||||
_ => 0.0,
|
||||
};
|
||||
e.data = i32::from(f.facing);
|
||||
if f.invisible {
|
||||
e.meta.push((0, serializer::BYTE, Value::Byte(0x20)));
|
||||
}
|
||||
// HangingEntity.DATA_DIRECTION, south by default.
|
||||
if f.facing != 3 {
|
||||
e.meta
|
||||
|
||||
@@ -959,6 +959,7 @@ impl Game<'_> {
|
||||
};
|
||||
let ctrl = self.keys.contains(&winit::keyboard::KeyCode::ControlLeft);
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let types_text = self.types_text();
|
||||
let Some(o) = self.world_options.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -970,7 +971,7 @@ impl Game<'_> {
|
||||
None => {
|
||||
let mut handled = false;
|
||||
rules.edit(|b| handled = b.key(key, ctrl, false));
|
||||
if !handled && !ctrl {
|
||||
if !handled && types_text {
|
||||
self.world_options_text(event.text.as_deref());
|
||||
}
|
||||
}
|
||||
@@ -984,7 +985,7 @@ impl Game<'_> {
|
||||
o.port.shortcut(shortcut, &mut self.clipboard);
|
||||
return;
|
||||
}
|
||||
if !o.port.key(key, ctrl, false) && !ctrl {
|
||||
if !o.port.key(key, ctrl, false) && types_text {
|
||||
self.world_options_text(event.text.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,17 +112,6 @@ pub(super) struct Levels {
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
/// The chunks of each level not in place (capability `world` lends
|
||||
/// them to server mods for a call).
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(super) fn parked_chunks(
|
||||
&mut self,
|
||||
) -> impl Iterator<Item = (Dim, &mut horizon_client::chunk_map::ChunkMap)> {
|
||||
self.parked
|
||||
.iter_mut()
|
||||
.map(|(d, p)| (*d, &mut p.world.chunks))
|
||||
}
|
||||
|
||||
/// The level not in place, in `dim`.
|
||||
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
|
||||
self.parked.get_mut(&dim)
|
||||
@@ -359,8 +348,6 @@ impl Game<'_> {
|
||||
};
|
||||
self.update_dedicated_streaming();
|
||||
self.update_world();
|
||||
// What server mods set in this level since its last tick.
|
||||
self.apply_mod_block_writes();
|
||||
result = if paused {
|
||||
self.tick_lan(true);
|
||||
Ok(())
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
//! Capability `input` (docs/mods.md): the use button on items a mod claims,
|
||||
//! the mod's own keys and the mouse wheel; and `render`'s zoom. With no mod
|
||||
//! claiming items, keys or the wheel, each hook here returns after one
|
||||
//! check.
|
||||
use super::Game;
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
#[cfg(feature = "mods")]
|
||||
use {super::Screen, horizon_mods::HeldItem};
|
||||
|
||||
/// A claimed item's use while the button is down.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) struct ModUse {
|
||||
item: HeldItem,
|
||||
/// `Game::ticks` when it started.
|
||||
since: u64,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
pub(super) fn mod_use(&mut self, _button: MouseButton, _pressed: bool) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mod_key(&mut self, _key: KeyCode, _pressed: bool, _repeat: bool) {}
|
||||
pub(super) fn mod_mouse_key(&mut self, _button: MouseButton, _pressed: bool) {}
|
||||
pub(super) fn mod_wheel(&mut self, _steps: f32) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
|
||||
None
|
||||
}
|
||||
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
|
||||
None
|
||||
}
|
||||
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ModZoom {
|
||||
pub(super) factor: f32,
|
||||
pub(super) smooth: bool,
|
||||
pub(super) turn: f32,
|
||||
}
|
||||
|
||||
/// A mod's minimap (`render.set-minimap`), clamped by the runtime.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ModMinimap {
|
||||
pub(super) corner: horizon_client::extras::Corner,
|
||||
pub(super) size: f32,
|
||||
pub(super) blocks_per_pixel: f32,
|
||||
pub(super) rotate: bool,
|
||||
pub(super) round: bool,
|
||||
}
|
||||
|
||||
/// A mod's far terrain (`render.set-far-terrain`), clamped by the runtime.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ModFarTerrain {
|
||||
pub(super) distance: u32,
|
||||
pub(super) reduced: bool,
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// The main hand's stack as `input` shows it; none when it is empty.
|
||||
pub(super) fn mod_held(&self) -> Option<HeldItem> {
|
||||
let stack = self.inventory.get(self.selected)?.as_ref()?;
|
||||
Some(HeldItem {
|
||||
id: stack.item.to_string(),
|
||||
count: stack.count,
|
||||
slot: self.selected as u32,
|
||||
})
|
||||
}
|
||||
/// The use button while playing: true when a mod claimed the held item,
|
||||
/// so the game does nothing else with it (no placing, eating, swing).
|
||||
pub(super) fn mod_use(&mut self, button: MouseButton, pressed: bool) -> bool {
|
||||
if button != MouseButton::Right || !self.mods.mods.claims_any() {
|
||||
return false;
|
||||
}
|
||||
if !pressed {
|
||||
return self.stop_mod_use();
|
||||
}
|
||||
let Some(item) = self.mod_held().filter(|h| self.mods.mods.claims(&h.id)) else {
|
||||
return false;
|
||||
};
|
||||
self.mods.mods.use_start(&item);
|
||||
self.mods.using = Some(ModUse {
|
||||
item,
|
||||
since: self.ticks,
|
||||
});
|
||||
true
|
||||
}
|
||||
/// Ends a claimed use: the button came up, the player switched slots,
|
||||
/// or a screen opened. False when none was going.
|
||||
pub(super) fn stop_mod_use(&mut self) -> bool {
|
||||
let Some(u) = self.mods.using.take() else {
|
||||
return false;
|
||||
};
|
||||
let ticks = self.ticks.saturating_sub(u.since).min(u64::from(u32::MAX)) as u32;
|
||||
self.mods.mods.use_stop(&u.item, ticks);
|
||||
true
|
||||
}
|
||||
/// Once a tick: a claimed use stops when its item leaves the hand or a
|
||||
/// screen opens, as vanilla's `releaseUsingItem`.
|
||||
pub(super) fn tick_mod_use(&mut self) {
|
||||
let Some(u) = &self.mods.using else {
|
||||
return;
|
||||
};
|
||||
let still = self
|
||||
.mod_held()
|
||||
.is_some_and(|h| h.id == u.item.id && h.slot == u.item.slot);
|
||||
if !still || self.screen != Screen::Playing {
|
||||
self.stop_mod_use();
|
||||
}
|
||||
}
|
||||
/// A key went down or up: a mod's registered key while playing (no
|
||||
/// screen) is its `key-down` / `key-up`. Vanilla's mapping on the same
|
||||
/// key still runs.
|
||||
pub(super) fn mod_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
|
||||
if repeat || !self.mods.mods.has_keys() {
|
||||
return;
|
||||
}
|
||||
// Over an item on a container screen, the mods' keys are `browser`
|
||||
// events (a recipe viewer's R and U).
|
||||
if pressed
|
||||
&& self.screen == Screen::Inventory
|
||||
&& self.title.is_none()
|
||||
&& !self.mod_browser_typing()
|
||||
&& let Some(name) = horizon_client::keybinds::key_name(key)
|
||||
&& let Some(stack) = self.mod_browser_pointed()
|
||||
{
|
||||
let stack = super::mod_panel::browser_stack(stack);
|
||||
self.mods.mods.browser_key(name, &stack);
|
||||
return;
|
||||
}
|
||||
if pressed && (self.screen != Screen::Playing || self.title.is_some()) {
|
||||
return;
|
||||
}
|
||||
if let Some(name) = horizon_client::keybinds::key_name(key) {
|
||||
self.mods.mods.key(name, pressed);
|
||||
}
|
||||
}
|
||||
/// A mouse button while playing: a mod's key bound to it
|
||||
/// (`key.mouse.4`...), as `mod_key`.
|
||||
pub(super) fn mod_mouse_key(&mut self, button: MouseButton, pressed: bool) {
|
||||
if self.mods.mods.has_keys() {
|
||||
let name = horizon_client::keybinds::mouse_name(button);
|
||||
self.mods.mods.key(&name, pressed);
|
||||
}
|
||||
}
|
||||
/// Wheel steps while playing: true when a mod holds the wheel, so the
|
||||
/// hotbar doesn't move.
|
||||
pub(super) fn mod_wheel(&mut self, steps: f32) -> bool {
|
||||
self.mods.mods.wheel(steps)
|
||||
}
|
||||
/// Playing, with nothing over the world: mods' keys and zoom apply.
|
||||
fn mod_playing(&self) -> bool {
|
||||
self.screen == Screen::Playing
|
||||
&& self.title.is_none()
|
||||
&& self.sign_edit.is_none()
|
||||
&& self.book.is_none()
|
||||
}
|
||||
/// Once a frame, before the mods run: a screen that opened lets their
|
||||
/// held keys up.
|
||||
pub(super) fn mod_input_frame(&mut self) {
|
||||
let playing = self.mod_playing();
|
||||
if self.mods.playing && !playing {
|
||||
self.mods.mods.release_keys();
|
||||
}
|
||||
self.mods.playing = playing;
|
||||
}
|
||||
/// The strongest zoom a mod asks for, while playing; the spyglass's
|
||||
/// own view wins.
|
||||
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
|
||||
if !self.mod_playing() || self.scoping() {
|
||||
return None;
|
||||
}
|
||||
let z = self.mods.mods.zoom()?;
|
||||
Some(ModZoom {
|
||||
factor: z.factor,
|
||||
smooth: z.smooth,
|
||||
turn: z.turn,
|
||||
})
|
||||
}
|
||||
/// The minimap the first mod showing one asks for.
|
||||
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
|
||||
use horizon_client::extras::Corner;
|
||||
use horizon_mods::RenderCorner;
|
||||
let m = self.mods.mods.minimap()?;
|
||||
Some(ModMinimap {
|
||||
corner: match m.corner {
|
||||
RenderCorner::TopLeft => Corner::TopLeft,
|
||||
RenderCorner::TopRight => Corner::TopRight,
|
||||
RenderCorner::BottomLeft => Corner::BottomLeft,
|
||||
RenderCorner::BottomRight => Corner::BottomRight,
|
||||
},
|
||||
size: m.size,
|
||||
blocks_per_pixel: m.blocks_per_pixel,
|
||||
rotate: m.rotate,
|
||||
round: m.round,
|
||||
})
|
||||
}
|
||||
/// The far terrain the first mod drawing one asks for.
|
||||
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
|
||||
let f = self.mods.mods.far_terrain()?;
|
||||
Some(ModFarTerrain {
|
||||
distance: f.distance,
|
||||
reduced: f.reduced,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
//! Capability `ui`'s item panel of a WebAssembly mod (WIT `interface
|
||||
//! browser`, docs/mods.md): its items and recipe page as the native panel's
|
||||
//! (`native_panel.rs`), and the panel's events back in the runtime's types.
|
||||
use super::{
|
||||
Game,
|
||||
native_hooks::{
|
||||
NativePanelEvent as BrowserEvent, NativeRecipeLayout, NativeRecipeView,
|
||||
NativeStack as BrowserStack,
|
||||
},
|
||||
native_panel::{Owner, Shown},
|
||||
native_panel_recipes::RecipePage,
|
||||
};
|
||||
use horizon_mods as m;
|
||||
|
||||
/// A WebAssembly mod's panel, converted once per `set-items` or
|
||||
/// `set-recipes`.
|
||||
impl Game<'_> {
|
||||
pub(super) fn panel_wasm_sync(&mut self) {
|
||||
let Some((index, version, items)) = self.mods.mods.browser_items() else {
|
||||
self.panel.wasm = None;
|
||||
return;
|
||||
};
|
||||
let owner = Owner::Wasm(index);
|
||||
let wasm = self.panel.wasm.as_ref();
|
||||
if wasm.is_some_and(|s| s.owner == owner && s.version == version) {
|
||||
return;
|
||||
}
|
||||
let items = items.iter().map(stack).collect();
|
||||
let recipes = self.mods.mods.browser_recipes(index).map(page);
|
||||
self.panel.wasm = Some(Shown {
|
||||
owner,
|
||||
version,
|
||||
items,
|
||||
recipes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stack(s: &m::BrowserStack) -> BrowserStack {
|
||||
BrowserStack {
|
||||
item: s.item.clone(),
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
}
|
||||
}
|
||||
/// A native stack as the runtime's (`browser_key`).
|
||||
pub(super) fn browser_stack(s: BrowserStack) -> m::BrowserStack {
|
||||
m::BrowserStack {
|
||||
item: s.item,
|
||||
count: s.count,
|
||||
components: s.components,
|
||||
}
|
||||
}
|
||||
fn layout(l: m::BrowserLayout) -> NativeRecipeLayout {
|
||||
match l {
|
||||
m::BrowserLayout::Grid => NativeRecipeLayout::Grid,
|
||||
m::BrowserLayout::Cooking => NativeRecipeLayout::Cooking,
|
||||
m::BrowserLayout::Single => NativeRecipeLayout::Single,
|
||||
m::BrowserLayout::Smithing => NativeRecipeLayout::Smithing,
|
||||
m::BrowserLayout::Brewing => NativeRecipeLayout::Brewing,
|
||||
}
|
||||
}
|
||||
fn stacks(list: &[m::BrowserStack]) -> Vec<BrowserStack> {
|
||||
list.iter().map(stack).collect()
|
||||
}
|
||||
pub(super) fn page(p: &m::RecipePage) -> RecipePage {
|
||||
RecipePage {
|
||||
title: p.title.clone(),
|
||||
tabs: stacks(&p.tabs),
|
||||
tab: p.tab,
|
||||
recipes: p
|
||||
.recipes
|
||||
.iter()
|
||||
.map(|r| NativeRecipeView {
|
||||
layout: layout(r.layout),
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
slots: r.slots.iter().map(|s| stacks(s)).collect(),
|
||||
results: stacks(&r.results),
|
||||
note: r.note.clone(),
|
||||
action: r.action,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub(super) fn event(e: BrowserEvent) -> m::BrowserEvent {
|
||||
match e {
|
||||
BrowserEvent::Clicked { index, right } => {
|
||||
m::BrowserEvent::Clicked(m::BrowserClick { index, right })
|
||||
}
|
||||
BrowserEvent::Search(text) => m::BrowserEvent::Search(text),
|
||||
// Not sent to WebAssembly mods: they hear their own keys
|
||||
// (`browser_key`).
|
||||
BrowserEvent::Key { key, stack } => m::BrowserEvent::Key(m::BrowserItemKey {
|
||||
key,
|
||||
stack: browser_stack(stack),
|
||||
}),
|
||||
BrowserEvent::SlotClicked {
|
||||
recipe,
|
||||
slot,
|
||||
stack,
|
||||
right,
|
||||
} => m::BrowserEvent::SlotClicked(m::BrowserSlotClick {
|
||||
recipe,
|
||||
slot,
|
||||
stack: browser_stack(stack),
|
||||
right,
|
||||
}),
|
||||
BrowserEvent::Action(i) => m::BrowserEvent::Action(i),
|
||||
BrowserEvent::Page(i) => m::BrowserEvent::Page(i),
|
||||
BrowserEvent::Back => m::BrowserEvent::Back,
|
||||
BrowserEvent::Tab(i) => m::BrowserEvent::Tab(i),
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
//! Capability `render` (docs/mods.md): the shapes mods keep on the HUD,
|
||||
//! drawn by the game's own HUD renderer, and their camera shakes. Mods
|
||||
//! never supply shader code. With no mod drawing, each hook here returns
|
||||
//! after one check.
|
||||
use super::Game;
|
||||
use horizon_client::hud::HudBatch;
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
horizon_client::{hud::rgba, items::ItemStack, text::Text, ui},
|
||||
horizon_mods::Shape,
|
||||
std::time::Instant,
|
||||
};
|
||||
|
||||
/// The camera's jolts from `render.shake`, while any is fading.
|
||||
#[cfg(feature = "mods")]
|
||||
#[derive(Default)]
|
||||
pub(super) struct Shake {
|
||||
jolts: Vec<Jolt>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
struct Jolt {
|
||||
start: Instant,
|
||||
strength: f32,
|
||||
seconds: f32,
|
||||
}
|
||||
|
||||
/// The HUD batch's colour (ABGR) from a mod's 0xAARRGGBB.
|
||||
#[cfg(feature = "mods")]
|
||||
fn argb(color: u32) -> u32 {
|
||||
let [a, r, g, b] = color.to_be_bytes();
|
||||
rgba(r, g, b, a)
|
||||
}
|
||||
|
||||
/// The view's turn at full strength, in degrees: yaw, then pitch.
|
||||
#[cfg(feature = "mods")]
|
||||
const SHAKE_DEGREES: (f32, f32) = (3.0, 2.0);
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Shake {
|
||||
pub(super) fn add(&mut self, strength: f32, seconds: f32) {
|
||||
self.jolts.push(Jolt {
|
||||
start: Instant::now(),
|
||||
strength,
|
||||
seconds,
|
||||
});
|
||||
}
|
||||
/// How far the view turns now (yaw, pitch degrees), `scale` being the
|
||||
/// player's Distortion Effects; fades jolts out and forgets ended ones.
|
||||
fn offset(&mut self, now: Instant, scale: f32) -> (f32, f32) {
|
||||
self.jolts
|
||||
.retain(|j| now.duration_since(j.start).as_secs_f32() < j.seconds);
|
||||
let mut strength = 0.0;
|
||||
for j in &self.jolts {
|
||||
let left = 1.0 - now.duration_since(j.start).as_secs_f32() / j.seconds;
|
||||
strength += j.strength * left * left;
|
||||
}
|
||||
let strength = strength.min(1.0) * scale.clamp(0.0, 1.0);
|
||||
if strength <= 0.0 {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
// Smooth noise: sines at unrelated rates, so it never repeats visibly.
|
||||
let t = self
|
||||
.jolts
|
||||
.first()
|
||||
.map_or(0.0, |j| now.duration_since(j.start).as_secs_f32());
|
||||
let wave = |a: f32, b: f32, c: f32| {
|
||||
((t * a).sin() * 0.5 + (t * b).sin() * 0.3 + (t * c).sin() * 0.2).clamp(-1.0, 1.0)
|
||||
};
|
||||
(
|
||||
strength * SHAKE_DEGREES.0 * wave(23.0, 37.3, 51.7),
|
||||
strength * SHAKE_DEGREES.1 * wave(29.1, 43.9, 61.3),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
pub(super) fn draw_mod_shapes(&self, _batch: &mut HudBatch) {}
|
||||
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
|
||||
(yaw, pitch)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// The running mods' HUD shapes, in order, over the HUD.
|
||||
pub(super) fn draw_mod_shapes(&self, batch: &mut HudBatch) {
|
||||
for shape in self.mods.mods.hud_shapes() {
|
||||
self.draw_shapes(batch, std::slice::from_ref(shape), (0.0, 0.0));
|
||||
}
|
||||
}
|
||||
/// Shapes with their GUI coordinates moved by `(ox, oy)`.
|
||||
pub(super) fn draw_shapes(&self, batch: &mut HudBatch, shapes: &[Shape], (ox, oy): (f32, f32)) {
|
||||
let atlas = &self.gui.atlas;
|
||||
for shape in shapes {
|
||||
match shape {
|
||||
Shape::Fill(f) => {
|
||||
let r = f.rect;
|
||||
batch.fill(atlas, ox + r.x, oy + r.y, r.w, r.h, argb(f.color));
|
||||
}
|
||||
Shape::Quad(q) => {
|
||||
let (a, b, c, d) = q.corners;
|
||||
let corners = [a, b, c, d].map(|p| [ox + p.x, oy + p.y]);
|
||||
batch.fill_quad(atlas, corners, argb(q.color));
|
||||
}
|
||||
Shape::Sprite(s) => {
|
||||
let r = s.rect;
|
||||
let name = s.sprite.strip_prefix("minecraft:").unwrap_or(&s.sprite);
|
||||
let name = format!("gui/sprites/{name}");
|
||||
// As vanilla's blitSprite: a frame or a button keeps its border.
|
||||
ui::blit_sprite(
|
||||
batch,
|
||||
atlas,
|
||||
&name,
|
||||
ox + r.x,
|
||||
oy + r.y,
|
||||
r.w,
|
||||
r.h,
|
||||
argb(s.color),
|
||||
);
|
||||
}
|
||||
Shape::Item(i) => {
|
||||
if self.res.items().get(&i.item).is_none() {
|
||||
continue;
|
||||
}
|
||||
let start = batch.vertices.len();
|
||||
let stack = ItemStack::new(i.item.as_str(), 1);
|
||||
let (x, y) = (ox + i.at.x, oy + i.at.y);
|
||||
self.draw_item_model(batch, &stack, x, y);
|
||||
batch.scale_since(start, x, y, i.scale, i.scale);
|
||||
}
|
||||
Shape::Text(t) => {
|
||||
let text = Text::literal(t.text.clone());
|
||||
let centre = if t.centered {
|
||||
(HudBatch::text_width(atlas, &t.text) / 2.0).floor()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let x = ox + t.at.x - centre;
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&text,
|
||||
x,
|
||||
oy + t.at.y,
|
||||
argb(t.color | 0xFF00_0000),
|
||||
t.shadow,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The view's yaw and pitch after the mods' camera shakes; unchanged
|
||||
/// while none is fading.
|
||||
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
|
||||
if self.mods.shake.jolts.is_empty() {
|
||||
return (yaw, pitch);
|
||||
}
|
||||
let scale = self.settings.file.double("screenEffectScale") as f32;
|
||||
let (dy, dp) = self.mods.shake.offset(Instant::now(), scale);
|
||||
(yaw + dy, (pitch + dp).clamp(-90.0, 90.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "mods"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn a_jolt_fades_out_and_distortion_scales_it() {
|
||||
let mut shake = Shake::default();
|
||||
shake.add(1.0, 2.0);
|
||||
let start = shake.jolts[0].start;
|
||||
let at = |s: f32| start + Duration::from_secs_f32(s);
|
||||
let size = |(y, p): (f32, f32)| y.abs() + p.abs();
|
||||
// Off with Distortion Effects at 0, inside the limits otherwise.
|
||||
assert_eq!(shake.offset(at(0.3), 0.0), (0.0, 0.0));
|
||||
let (y, p) = shake.offset(at(0.3), 1.0);
|
||||
assert!(y.abs() <= SHAKE_DEGREES.0 && p.abs() <= SHAKE_DEGREES.1);
|
||||
// Fading: the largest turn over a window shrinks.
|
||||
let mut peak = |from: f32| {
|
||||
(0..50)
|
||||
.map(|i| size(shake.offset(at(from + i as f32 * 0.002), 1.0)))
|
||||
.fold(0.0, f32::max)
|
||||
};
|
||||
let early = peak(0.1);
|
||||
let late = peak(1.6);
|
||||
assert!(late < early, "{late} !< {early}");
|
||||
// Gone once it ends.
|
||||
assert_eq!(shake.offset(at(2.1), 1.0), (0.0, 0.0));
|
||||
assert!(shake.jolts.is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,122 +0,0 @@
|
||||
//! Extras that are official mods now (`horizon_client::moved_extras`): on
|
||||
//! the first start after the update, a player who had one on gets the mod
|
||||
//! installed from Horizon's registry, turned on, with their settings, and
|
||||
//! one line in the chat saying so. The download runs on its own thread; if
|
||||
//! it fails (offline), the game tries again at the next start.
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl super::Game<'_> {
|
||||
pub(super) fn start_moved_extras(&mut self) {}
|
||||
pub(super) fn poll_moved_extras(&mut self) {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
mod with_mods {
|
||||
use super::super::Game;
|
||||
use horizon_client::{
|
||||
moved_extras::{self, Moved},
|
||||
text::{self, Text},
|
||||
};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
type Done = Vec<(&'static Moved, Result<(), String>)>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::game) struct MovedState {
|
||||
/// The installs under way.
|
||||
running: Option<Receiver<Done>>,
|
||||
/// Lines for the player, shown once a world is open.
|
||||
notices: Vec<Text>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// At start: remembers the moved extras that were off, and installs
|
||||
/// the ones that were on, in the background.
|
||||
pub(in crate::game) fn start_moved_extras(&mut self) {
|
||||
let off = moved_extras::nothing_to_do(&self.settings.extras);
|
||||
let pending = moved_extras::pending(&self.settings.extras);
|
||||
if off.is_empty() && pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !off.is_empty() {
|
||||
let e = &mut self.settings.extras;
|
||||
e.moved.extend(off.iter().map(|m| m.id.to_owned()));
|
||||
self.settings.save(&Self::options_path());
|
||||
}
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let extras = self.settings.extras.clone();
|
||||
let dir = Self::options_path()
|
||||
.parent()
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_default();
|
||||
let (tx, rx) = channel();
|
||||
self.mods.moved.running = Some(rx);
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Moved extras".into())
|
||||
.spawn(move || {
|
||||
let done = match moved_extras::fetch_index() {
|
||||
Ok(index) => pending
|
||||
.into_iter()
|
||||
.map(|m| (m, moved_extras::install(&index, m, &extras, &dir)))
|
||||
.collect(),
|
||||
Err(e) => pending.into_iter().map(|m| (m, Err(e.clone()))).collect(),
|
||||
};
|
||||
let _ = tx.send(done);
|
||||
});
|
||||
if spawned.is_err() {
|
||||
self.mods.moved.running = None;
|
||||
}
|
||||
}
|
||||
/// Once a frame: the installs that finished turn their mods on;
|
||||
/// their lines wait for a world.
|
||||
pub(in crate::game) fn poll_moved_extras(&mut self) {
|
||||
if let Some(rx) = &self.mods.moved.running
|
||||
&& let Ok(done) = rx.try_recv()
|
||||
{
|
||||
self.mods.moved.running = None;
|
||||
let mut installed = false;
|
||||
for (m, result) in done {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.settings.extras.moved.push(m.id.to_owned());
|
||||
self.settings.mods.enabled.insert(m.id.to_owned());
|
||||
// The install wrote the key to horizon-mods.txt;
|
||||
// the settings save below must keep it.
|
||||
let keys = self.settings.mods.keys.entry(m.id.to_owned()).or_default();
|
||||
for (name, key) in m.keys(&self.settings.extras) {
|
||||
keys.entry(name.to_owned()).or_insert(key);
|
||||
}
|
||||
self.settings.mods.keys.retain(|_, k| !k.is_empty());
|
||||
self.mods.moved.notices.push(Text::literal(m.notice()));
|
||||
installed = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: {} not installed, next start retries: {e}", m.id);
|
||||
self.mods.moved.notices.push(
|
||||
Text::literal(format!(
|
||||
"{} moved to the mod browser; it couldn't be installed for you yet, Horizon tries again at the next start",
|
||||
m.name
|
||||
))
|
||||
.with_color(text::GRAY),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if installed {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.start_mods();
|
||||
}
|
||||
}
|
||||
if !self.mods.moved.notices.is_empty() && self.player.is_some() && self.title.is_none()
|
||||
{
|
||||
for line in std::mem::take(&mut self.mods.moved.notices) {
|
||||
self.system_message(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) use with_mods::MovedState;
|
||||
@@ -4,7 +4,8 @@
|
||||
//! mob is under the crosshair within 64 blocks, as its team's name tag
|
||||
//! visibility allows, in the team's colours, with its score in the
|
||||
//! objective displayed below names. Other players' names (on a LAN host
|
||||
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows.
|
||||
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows,
|
||||
//! and the player's own in third person with the `ownNameTag` extra.
|
||||
use super::{Game, view_vector};
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, save::uuid_text,
|
||||
@@ -68,6 +69,27 @@ impl Game<'_> {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Horizon's own name tag extra (`ownNameTag`): the player's own model and
|
||||
/// name, while that model shows in third person. Vanilla never names the
|
||||
/// camera entity (`LivingEntityRenderer.shouldShowName`).
|
||||
fn own_name_tag(&self) -> Option<(&Mob, String)> {
|
||||
if !self.settings.extras.own_name_tag
|
||||
|| self.camera_type.first_person()
|
||||
|| self.sleeping.is_some()
|
||||
|| self.spectator()
|
||||
|| self.potions.effects.get("invisibility").is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let m = self.player_model.as_ref()?;
|
||||
// The name others see: the profile's on a server (single player's
|
||||
// server thread included), else the local player's.
|
||||
let name = match self.remote.as_ref() {
|
||||
Some(r) if !r.profile_name.is_empty() => r.profile_name.clone(),
|
||||
_ => self.acting_player_name().to_owned(),
|
||||
};
|
||||
Some((m, name))
|
||||
}
|
||||
/// The names the name tags show this frame (the control socket's status).
|
||||
pub(super) fn shown_name_tags(&self, camera: glam::DVec3) -> Vec<String> {
|
||||
self.name_tags(camera, 1.0)
|
||||
@@ -88,7 +110,10 @@ impl Game<'_> {
|
||||
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
|
||||
(at.distance_squared(camera) < 64.0 * 64.0).then(|| (m, name.clone(), false))
|
||||
});
|
||||
let players = players.into_iter().map(|(m, name)| (m, name, true));
|
||||
let players = players
|
||||
.into_iter()
|
||||
.chain(self.own_name_tag())
|
||||
.map(|(m, name)| (m, name, true));
|
||||
// Who is riding whom: asked once per tag below, and rescanning every mob
|
||||
// for each tag made this quadratic in the number of loaded mobs.
|
||||
let ridden: std::collections::HashSet<u32> =
|
||||
|
||||
@@ -506,8 +506,8 @@ impl Game<'_> {
|
||||
pub(super) fn list_native_mods(&mut self) {
|
||||
let mut mods = Vec::new();
|
||||
self.native_mods(&mut mods);
|
||||
// A WebAssembly mod of the same id doesn't run (`start_mods`): the
|
||||
// native row stands for it, and alone toggles its `mod.<id>` line.
|
||||
// A WebAssembly mod of the same id: the native row stands for it,
|
||||
// and alone toggles its `mod.<id>` line.
|
||||
self.settings
|
||||
.mods
|
||||
.installed
|
||||
@@ -525,7 +525,6 @@ impl Game<'_> {
|
||||
description: m.description,
|
||||
problem: None,
|
||||
dev: false,
|
||||
has_settings: false,
|
||||
native: Some(page),
|
||||
});
|
||||
}
|
||||
@@ -575,11 +574,11 @@ impl Game<'_> {
|
||||
self.native_wheel(steps, &mut claimed);
|
||||
claimed
|
||||
}
|
||||
/// A client mod's command: a native mod's first, then a WebAssembly mod's.
|
||||
/// A native mod's command.
|
||||
pub(super) fn client_mod_command(&mut self, command: &str) -> bool {
|
||||
let mut ran = false;
|
||||
self.native_command(command, &mut ran);
|
||||
ran || self.mod_command(command)
|
||||
ran
|
||||
}
|
||||
/// The zoom in effect while playing (a native mod's); the spyglass's own
|
||||
/// view wins.
|
||||
|
||||
@@ -27,21 +27,15 @@ const MARGIN: f32 = 8.0;
|
||||
/// Items a panel may hold.
|
||||
const ITEMS: usize = 8192;
|
||||
|
||||
/// Whose panel it is: a native mod's id, or a running WebAssembly mod's
|
||||
/// index.
|
||||
/// Whose panel it is: a native mod's id.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) enum Owner {
|
||||
Native(String),
|
||||
#[cfg(feature = "mods")]
|
||||
Wasm(usize),
|
||||
}
|
||||
impl Owner {
|
||||
fn native_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Owner::Native(id) => Some(id),
|
||||
#[cfg(feature = "mods")]
|
||||
Owner::Wasm(_) => None,
|
||||
}
|
||||
fn native_id(&self) -> &str {
|
||||
let Owner::Native(id) = self;
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +55,6 @@ pub(super) struct Shown {
|
||||
pub(super) struct Panel {
|
||||
/// A native mod's panel: the last one to show something.
|
||||
native: Option<Shown>,
|
||||
/// A WebAssembly mod's, converted once per change.
|
||||
#[cfg(feature = "mods")]
|
||||
pub(super) wasm: Option<Shown>,
|
||||
/// The last native change.
|
||||
versions: u64,
|
||||
/// The items' owner and version they were made from.
|
||||
@@ -79,16 +70,9 @@ pub(super) struct Panel {
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
/// What the panel shows: a native mod's (one `on`), else a
|
||||
/// WebAssembly mod's.
|
||||
/// What the panel shows: a native mod's, one `on`.
|
||||
pub(super) fn shown(&self, on: impl Fn(&str) -> bool) -> Option<&Shown> {
|
||||
let native = self
|
||||
.native
|
||||
.as_ref()
|
||||
.filter(|s| s.owner.native_id().is_some_and(&on));
|
||||
#[cfg(feature = "mods")]
|
||||
let native = native.or(self.wasm.as_ref());
|
||||
native
|
||||
self.native.as_ref().filter(|s| on(s.owner.native_id()))
|
||||
}
|
||||
/// `native_panel_items`.
|
||||
pub(super) fn show_items(&mut self, mod_id: &str, mut items: Vec<BrowserStack>) {
|
||||
@@ -111,7 +95,7 @@ impl Panel {
|
||||
let own = self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.owner.native_id() == Some(mod_id));
|
||||
.is_some_and(|s| s.owner.native_id() == mod_id);
|
||||
if !own {
|
||||
if !showing {
|
||||
return None;
|
||||
@@ -187,7 +171,6 @@ impl Game<'_> {
|
||||
if self.screen != Screen::Inventory || self.title.is_some() {
|
||||
return None;
|
||||
}
|
||||
self.panel_wasm_sync();
|
||||
let disabled = &self.settings.mods.disabled;
|
||||
let Some(shown) = self.panel.shown(|id| !disabled.contains(id)) else {
|
||||
self.panel.of = None;
|
||||
@@ -220,14 +203,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// The player did `event` on `owner`'s panel.
|
||||
pub(super) fn panel_event(&mut self, owner: &Owner, event: BrowserEvent) {
|
||||
match owner {
|
||||
Owner::Native(id) => self.native_panel_event(id, &event),
|
||||
#[cfg(feature = "mods")]
|
||||
Owner::Wasm(i) => self
|
||||
.mods
|
||||
.mods
|
||||
.browser_event(*i, super::mod_panel::event(event)),
|
||||
}
|
||||
let Owner::Native(id) = owner;
|
||||
self.native_panel_event(id, &event);
|
||||
}
|
||||
/// The panel's parts: right of the window, from the top of the screen to
|
||||
/// the search box at its foot. None when the screen has no room for it.
|
||||
@@ -470,6 +447,7 @@ impl Game<'_> {
|
||||
let shortcut = self.edit_shortcut(event);
|
||||
let ctrl = self.has_control_down();
|
||||
let shift = self.has_shift_down();
|
||||
let types_text = self.types_text();
|
||||
let Some(search) = self.panel.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -481,7 +459,7 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
};
|
||||
if !handled
|
||||
&& !ctrl
|
||||
&& types_text
|
||||
&& let Some(text) = &event.text
|
||||
{
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
@@ -550,7 +528,7 @@ impl Game<'_> {
|
||||
if self.screen != Screen::Inventory || self.title.is_some() || self.mod_browser_typing() {
|
||||
return;
|
||||
}
|
||||
let Some(owner) = self.browser_sync().filter(|o| o.native_id().is_some()) else {
|
||||
let Some(owner) = self.browser_sync() else {
|
||||
return;
|
||||
};
|
||||
if let Some(stack) = self.mod_browser_pointed() {
|
||||
@@ -582,12 +560,6 @@ pub(super) fn browser_stack(
|
||||
})
|
||||
}
|
||||
|
||||
/// No WebAssembly mods: no panel of theirs (`mod_panel.rs` with them).
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
fn panel_wasm_sync(&mut self) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BrowserStack, Owner, Panel, RecipePage};
|
||||
@@ -655,40 +627,4 @@ mod tests {
|
||||
panel.show_items("recipes", Vec::new());
|
||||
assert!(panel.shown(|_| true).is_none());
|
||||
}
|
||||
|
||||
/// A native mod's panel wins over a WebAssembly mod's, and events go
|
||||
/// back to a WebAssembly owner in the runtime's form.
|
||||
#[cfg(feature = "mods")]
|
||||
#[test]
|
||||
fn native_owner_wins_over_webassembly() {
|
||||
use super::{BrowserEvent, Shown};
|
||||
use crate::game::mod_panel as wasm;
|
||||
let mut panel = Panel {
|
||||
wasm: Some(Shown {
|
||||
owner: Owner::Wasm(0),
|
||||
version: 7,
|
||||
items: vec![stack("minecraft:stone")],
|
||||
recipes: None,
|
||||
}),
|
||||
..Panel::default()
|
||||
};
|
||||
let owner = |p: &Panel| p.shown(|_| true).map(|s| s.owner.clone());
|
||||
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
|
||||
panel.show_items("recipes", vec![stack("minecraft:dirt")]);
|
||||
assert_eq!(owner(&panel), Some(Owner::Native("recipes".into())));
|
||||
panel.show_items("recipes", Vec::new());
|
||||
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
|
||||
|
||||
let event = BrowserEvent::SlotClicked {
|
||||
recipe: 2,
|
||||
slot: 9,
|
||||
stack: stack("minecraft:oak_planks"),
|
||||
right: true,
|
||||
};
|
||||
let horizon_mods::BrowserEvent::SlotClicked(c) = wasm::event(event) else {
|
||||
panic!("a slot click");
|
||||
};
|
||||
assert_eq!((c.recipe, c.slot, c.right), (2, 9, true));
|
||||
assert_eq!(c.stack.item, "minecraft:oak_planks");
|
||||
}
|
||||
}
|
||||
@@ -320,78 +320,3 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability `recipes` of WebAssembly mods: the same tables in the
|
||||
/// runtime's types.
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// Capability `recipes`'s tables and known displays for this tick's
|
||||
/// snapshot; nothing while no running mod reads them.
|
||||
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<horizon_mods::Recipes>>, Arc<[i32]>) {
|
||||
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
|
||||
return (None, Arc::from([]));
|
||||
}
|
||||
if self.mods.recipes.is_none() {
|
||||
self.mods.recipes = self.made_recipes().map(|t| Arc::new(wasm::recipes(&t)));
|
||||
}
|
||||
(self.mods.recipes.clone(), self.known_displays())
|
||||
}
|
||||
}
|
||||
|
||||
/// The native tables as the WebAssembly runtime's.
|
||||
#[cfg(feature = "mods")]
|
||||
mod wasm {
|
||||
use super::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
|
||||
use horizon_mods as m;
|
||||
|
||||
fn kind(k: RecipeKind) -> m::RecipeKind {
|
||||
match k {
|
||||
RecipeKind::CraftingShaped => m::RecipeKind::CraftingShaped,
|
||||
RecipeKind::CraftingShapeless => m::RecipeKind::CraftingShapeless,
|
||||
RecipeKind::Smelting => m::RecipeKind::Smelting,
|
||||
RecipeKind::Blasting => m::RecipeKind::Blasting,
|
||||
RecipeKind::Smoking => m::RecipeKind::Smoking,
|
||||
RecipeKind::Campfire => m::RecipeKind::Campfire,
|
||||
RecipeKind::Stonecutting => m::RecipeKind::Stonecutting,
|
||||
RecipeKind::Smithing => m::RecipeKind::Smithing,
|
||||
RecipeKind::Brewing => m::RecipeKind::Brewing,
|
||||
}
|
||||
}
|
||||
fn stacks(list: &[RecipeStack]) -> Vec<m::RecipeStack> {
|
||||
list.iter()
|
||||
.map(|s| m::RecipeStack {
|
||||
item: s.item,
|
||||
count: s.count,
|
||||
components: s.components.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn recipe(r: &Recipe) -> m::Recipe {
|
||||
m::Recipe {
|
||||
id: r.id.clone(),
|
||||
kind: kind(r.kind),
|
||||
category: r.category.clone(),
|
||||
group: r.group.clone(),
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
slots: r.slots.iter().map(|s| stacks(s)).collect(),
|
||||
results: stacks(&r.results),
|
||||
cooking_time: r.cooking_time,
|
||||
experience: r.experience,
|
||||
book_id: r.book_id,
|
||||
}
|
||||
}
|
||||
fn item(i: &RecipeItem) -> m::RecipeItem {
|
||||
m::RecipeItem {
|
||||
id: i.id.clone(),
|
||||
name: i.name.clone(),
|
||||
tags: i.tags.clone(),
|
||||
}
|
||||
}
|
||||
pub(super) fn recipes(t: &Recipes) -> m::Recipes {
|
||||
m::Recipes {
|
||||
items: t.items.iter().map(item).collect(),
|
||||
recipes: t.recipes.iter().map(recipe).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,18 @@ pub(super) const CONTROL_HELP: &[&str] = &[
|
||||
impl Game<'_> {
|
||||
pub(super) fn save_title_options(&mut self) {
|
||||
if let Some(title) = &self.title
|
||||
&& title.last_server != self.settings.file.string("lastServer")
|
||||
&& (title.last_server != self.settings.file.string("lastServer")
|
||||
|| title.skip_multiplayer_warning
|
||||
!= self.settings.file.bool("skipMultiplayerWarning"))
|
||||
{
|
||||
self.settings.file.set(
|
||||
"lastServer",
|
||||
horizon_client::options_file::V::Str(title.last_server.clone()),
|
||||
);
|
||||
self.settings.file.set(
|
||||
"skipMultiplayerWarning",
|
||||
horizon_client::options_file::V::Bool(title.skip_multiplayer_warning),
|
||||
);
|
||||
self.settings.save(&Self::options_path());
|
||||
}
|
||||
}
|
||||
@@ -196,7 +202,6 @@ impl Game<'_> {
|
||||
Event::Changed => {
|
||||
self.settings.save(&Self::options_path());
|
||||
self.apply_settings();
|
||||
self.sync_mods();
|
||||
self.sync_agent_control();
|
||||
self.ui.options.agent_status = self.agent_status_lines();
|
||||
}
|
||||
@@ -257,7 +262,6 @@ impl Game<'_> {
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
super::packs::open_folder(&dir);
|
||||
}
|
||||
Event::ModSettings(i) => self.open_mod_screen(i, true),
|
||||
Event::NativeSettings(i) => self.native_settings_refresh(i),
|
||||
Event::NativeButton(i, b) => self.native_settings_event(i, b),
|
||||
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
|
||||
@@ -290,8 +294,10 @@ impl Game<'_> {
|
||||
);
|
||||
r.set_hdr(
|
||||
s.horizon.hdr,
|
||||
s.horizon.hdr_paper_white as f32,
|
||||
s.horizon.hdr_peak as f32,
|
||||
s.horizon.paper_white_nits() as f32,
|
||||
s.horizon.peak_nits() as f32,
|
||||
s.horizon.hdr_black_level as f32 / 100.0,
|
||||
s.horizon.hdr_ui_brightness as f32 / 100.0,
|
||||
&Self::options_path().with_file_name(horizon_client::graphics::HDR_MARKER),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -822,6 +822,10 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.pad.menu.os_hidden = !show;
|
||||
// Controller menus leave the native pointer to its owning player.
|
||||
if !self.keyboard_mouse {
|
||||
return;
|
||||
}
|
||||
if let Some(window) = &self.window {
|
||||
window.set_cursor_visible(show && !self.captured);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,17 @@ impl CameraType {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LivingEntityRenderer.isBodyVisible` and `isInvisibleTo` for the player's
|
||||
/// own body, seen by itself: the Invisibility effect (`Entity.isInvisible`)
|
||||
/// hides it unless its team sees friendly invisibles, where vanilla draws it
|
||||
/// translucent.
|
||||
fn body_visible(
|
||||
effects: &horizon_client::effects::PlayerEffects,
|
||||
team: Option<&horizon_client::scoreboard::Team>,
|
||||
) -> bool {
|
||||
let invisible = effects.get("invisibility").is_some();
|
||||
!horizon_client::scoreboard::invisible_to(team, team, false, invisible)
|
||||
}
|
||||
/// The player's model type (`PlayerModel` with the default skin).
|
||||
pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
|
||||
let models = horizon_client::mobs::EntityModelDocument::load(root).ok()?;
|
||||
@@ -316,6 +327,11 @@ impl Game<'_> {
|
||||
/// Keep the player's model in step with the player (`LivingEntity.tick`
|
||||
/// body turning, `WalkAnimationState`, the swing and the pose).
|
||||
pub(super) fn tick_player_model(&mut self) {
|
||||
let scale = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:scale").ok())
|
||||
.unwrap_or(1.0) as f32;
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
self.player_model = None;
|
||||
return;
|
||||
@@ -370,6 +386,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
m.extra.ints[1] = bed;
|
||||
m.extra.model_scale = scale;
|
||||
let at = Vec3::new(
|
||||
position.x,
|
||||
position.y - if crouching { 0.125 } else { 0.0 },
|
||||
@@ -454,17 +471,28 @@ impl Game<'_> {
|
||||
pub(super) fn own_body_in_view(&self) -> bool {
|
||||
!self.camera_type.first_person() || self.sleeping.is_some()
|
||||
}
|
||||
/// Whether the player's own model shows in the world view.
|
||||
/// Whether the player's own model shows in the world view (its body or,
|
||||
/// while invisible, only its armour and held items).
|
||||
pub(super) fn player_model_shown(&self) -> bool {
|
||||
// A shader pack's shadow pass draws the player in first person too (shadowPlayer).
|
||||
(self.own_body_in_view() || self.pack_shadow_casters.1)
|
||||
&& !self.spectator()
|
||||
&& self.potions.effects.get("minecraft:invisibility").is_none()
|
||||
&& self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| !p.health.dead_or_dying())
|
||||
}
|
||||
/// The player's own team: a server's client is in it by its profile name.
|
||||
fn own_team(&self) -> Option<&horizon_client::scoreboard::Team> {
|
||||
match &self.remote {
|
||||
Some(r) => self.score.board.players_team(&r.profile_name),
|
||||
None => self.player_team(),
|
||||
}
|
||||
}
|
||||
/// Whether the player's own body is drawn (`body_visible`).
|
||||
pub(super) fn own_body_visible(&self) -> bool {
|
||||
body_visible(&self.potions.effects, self.own_team())
|
||||
}
|
||||
/// The player's model for the entity pass in third person.
|
||||
pub(super) fn player_model_geometry(
|
||||
&self,
|
||||
@@ -487,7 +515,13 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let start = out.len();
|
||||
horizon_client::mobs::render::render_with(
|
||||
let body = self.own_body_visible();
|
||||
let render = if body {
|
||||
horizon_client::mobs::render::render_with
|
||||
} else {
|
||||
horizon_client::mobs::render::render_layers
|
||||
};
|
||||
render(
|
||||
m,
|
||||
alpha as f32,
|
||||
camera,
|
||||
@@ -498,18 +532,32 @@ impl Game<'_> {
|
||||
out,
|
||||
);
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
// CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks).
|
||||
if self.potions.effects.get("invisibility").is_none() {
|
||||
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
}
|
||||
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
|
||||
// in its team by its profile name.
|
||||
// LivingEntity.isCurrentlyGlowing: the Glowing effect.
|
||||
if self.potions.effects.get("glowing").is_some() {
|
||||
let team = match &self.remote {
|
||||
Some(r) => self.score.board.players_team(&r.profile_name),
|
||||
None => self.player_team(),
|
||||
};
|
||||
self.mark_outline(&out[start..], super::glowing::team_color(team));
|
||||
let color = super::glowing::team_color(self.own_team());
|
||||
// LivingEntityRenderer.getRenderType: an invisible glowing body
|
||||
// still draws its outline.
|
||||
if !body {
|
||||
let mut silhouette = Vec::new();
|
||||
horizon_client::mobs::render::render(
|
||||
m,
|
||||
alpha as f32,
|
||||
camera,
|
||||
&lookup,
|
||||
packed,
|
||||
self.res.mob_data(),
|
||||
&mut silhouette,
|
||||
);
|
||||
self.mark_outline(&silhouette, color);
|
||||
}
|
||||
self.mark_outline(&out[start..], color);
|
||||
}
|
||||
}
|
||||
/// `extractEntityInInventoryFollowsMouse`: the player in the box
|
||||
@@ -540,6 +588,9 @@ impl Game<'_> {
|
||||
let x_angle = ((cx - self.cursor.0) / 40.0).atan();
|
||||
let y_angle = ((cy - self.cursor.1) / 40.0).atan();
|
||||
let mut m = m.clone();
|
||||
// `extractEntityInInventory` divides the size by the entity's scale: the
|
||||
// preview is the same size whatever the player's scale.
|
||||
m.extra.model_scale = 0.0;
|
||||
// The render state: still, facing out, the head toward the cursor.
|
||||
m.previous = m.position;
|
||||
m.body_yaw = 180.0 + x_angle * 20.0;
|
||||
@@ -554,7 +605,13 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
|
||||
let mut verts = Vec::new();
|
||||
horizon_client::mobs::render::render_with(
|
||||
// The same render state as in the world: an invisible body is not drawn.
|
||||
let render = if self.own_body_visible() {
|
||||
horizon_client::mobs::render::render_with
|
||||
} else {
|
||||
horizon_client::mobs::render::render_layers
|
||||
};
|
||||
render(
|
||||
&m,
|
||||
1.0,
|
||||
camera,
|
||||
@@ -565,7 +622,9 @@ impl Game<'_> {
|
||||
&mut verts,
|
||||
);
|
||||
let chest = self.armor[1].as_ref().map(|s| &*s.item);
|
||||
self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
if self.potions.effects.get("invisibility").is_none() {
|
||||
self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
}
|
||||
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
let height = if m.extra.crouching { 1.5 } else { 1.8 };
|
||||
@@ -872,6 +931,73 @@ mod preview_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invisible_player_hides_its_own_body() {
|
||||
use horizon_client::{effects::EffectInstance, scoreboard::Scoreboard};
|
||||
// The effect as a server's UpdateMobEffect or a drunk potion adds it.
|
||||
let id = horizon_client::brewing::effect("minecraft:invisibility")
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut effects = horizon_client::effects::PlayerEffects::default();
|
||||
assert!(body_visible(&effects, None));
|
||||
effects.add(EffectInstance::new(id, 3600, 0));
|
||||
assert!(!body_visible(&effects, None), "no team: the body is hidden");
|
||||
// Entity.isInvisibleTo: a team that sees friendly invisibles sees it.
|
||||
let mut board = Scoreboard::default();
|
||||
let team = board.add_team("blue");
|
||||
assert!(body_visible(&effects, Some(team)));
|
||||
team.see_friendly_invisibles = false;
|
||||
assert!(!body_visible(&effects, Some(team)));
|
||||
}
|
||||
|
||||
/// LivingEntityRenderer.submit: an invisible body is not drawn, its
|
||||
/// HumanoidArmorLayer is.
|
||||
#[test]
|
||||
fn an_invisible_body_keeps_its_armour() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
let Some(kind) = player_type(&root) else {
|
||||
return;
|
||||
};
|
||||
let m = Mob::new(1, kind.clone(), Vec3::ZERO, 0.0);
|
||||
let armour = [(&*kind.model, [0.5, 0.5, 0.75, 0.75], u32::MAX)];
|
||||
let atlas = |_: &str| Some([0.0, 0.0, 0.25, 0.25]);
|
||||
let data = horizon_client::mobs::MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: Default::default(),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
pois: Default::default(),
|
||||
};
|
||||
let draw = |body: bool| {
|
||||
let mut out = Vec::new();
|
||||
let render = if body {
|
||||
horizon_client::mobs::render::render_with
|
||||
} else {
|
||||
horizon_client::mobs::render::render_layers
|
||||
};
|
||||
render(
|
||||
&m,
|
||||
1.0,
|
||||
glam::DVec3::ZERO,
|
||||
&atlas,
|
||||
0xF0F0,
|
||||
&data,
|
||||
&armour,
|
||||
&mut out,
|
||||
);
|
||||
out
|
||||
};
|
||||
let (shown, hidden) = (draw(true), draw(false));
|
||||
assert!(shown.iter().any(|v| v.uv[0] <= 0.25), "the body draws");
|
||||
assert!(!hidden.is_empty(), "the armour still draws");
|
||||
assert!(hidden.iter().all(|v| v.uv[0] >= 0.5), "only the armour");
|
||||
assert_eq!(hidden.len() * 2, shown.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burning_inventory_preview_keeps_visible_fullbright_flames() {
|
||||
let rect = |u0| horizon_client::hud::Rect {
|
||||
|
||||
@@ -180,6 +180,7 @@ impl Game<'_> {
|
||||
}
|
||||
let ctrl = self.has_control_down();
|
||||
let shift = self.has_shift_down();
|
||||
let types_text = self.types_text();
|
||||
let Some(search) = self.recipe_ui.search.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -188,7 +189,7 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
};
|
||||
if !handled
|
||||
&& !ctrl
|
||||
&& types_text
|
||||
&& let Some(text) = &event.text
|
||||
{
|
||||
let text: String = text.chars().filter(|c| !c.is_control()).collect();
|
||||
@@ -338,7 +339,7 @@ impl Game<'_> {
|
||||
ui.client_known = Some(known.clone());
|
||||
ui.client_version += 1;
|
||||
}
|
||||
/// Every entry of the book, in its order (capability `recipes`).
|
||||
/// Every entry of the book, in its order (`native_recipes`).
|
||||
pub(super) fn recipe_book_entries(&mut self) -> Vec<Entry> {
|
||||
self.ensure_book();
|
||||
self.recipe_ui
|
||||
@@ -347,7 +348,7 @@ impl Game<'_> {
|
||||
.map_or_else(Vec::new, |b| b.entries.clone())
|
||||
}
|
||||
/// The display ids the player's book holds, and a number that changes
|
||||
/// whenever they do (capability `recipes`).
|
||||
/// whenever they do (`native_known_recipes`).
|
||||
pub(super) fn recipe_book_displays(&mut self) -> (u64, Vec<i32>) {
|
||||
self.sync_client_book();
|
||||
(
|
||||
@@ -861,14 +862,14 @@ impl Game<'_> {
|
||||
self.recipe_ui.ghost.clear();
|
||||
self.place_recipe(book_type, entry_index, use_max);
|
||||
}
|
||||
/// Whether the open screen takes `recipes.place`: a crafting grid's
|
||||
/// Whether the open screen takes `native_place_recipe`: a crafting grid's
|
||||
/// book (the inventory's or a crafting table's).
|
||||
pub(super) fn mod_recipe_screen(&self) -> bool {
|
||||
self.screen == Screen::Inventory
|
||||
&& self.active_book() == Some(BookType::Crafting)
|
||||
&& !self.spectator()
|
||||
}
|
||||
/// `recipes.place`: the book's click on the recipe with this display
|
||||
/// `native_place_recipe`: the book's click on the recipe with this display
|
||||
/// id, refused as `handlePlaceRecipe` refuses it (no crafting screen,
|
||||
/// not in the player's book; an impossible one in `place_recipe`).
|
||||
pub(super) fn mod_place_recipe(&mut self, display_id: i32, all: bool) {
|
||||
|
||||
@@ -325,7 +325,9 @@ impl Game<'_> {
|
||||
&self.options.resources,
|
||||
));
|
||||
}
|
||||
let skip = self.settings.file.bool("skipMultiplayerWarning");
|
||||
if let Some(title) = &mut self.title {
|
||||
title.skip_multiplayer_warning = skip;
|
||||
title.failed(error);
|
||||
}
|
||||
self.capture(false);
|
||||
@@ -556,6 +558,8 @@ impl Game<'_> {
|
||||
self.titles.overlay_time = 0;
|
||||
// GameRenderer.resetData (Minecraft.disconnect).
|
||||
self.item_activation.reset();
|
||||
// A world is leaving either way: its icon attempt goes with it.
|
||||
self.world_icon = None;
|
||||
if let Some(mut r) = self.remote.take() {
|
||||
r.session.disconnect();
|
||||
}
|
||||
@@ -608,6 +612,7 @@ impl Game<'_> {
|
||||
let saves = std::env::current_dir()?.join("saves");
|
||||
let mut title = horizon_client::title::Title::new(saves.clone(), &self.options.resources);
|
||||
title.last_server = self.settings.file.string("lastServer");
|
||||
title.skip_multiplayer_warning = self.settings.file.bool("skipMultiplayerWarning");
|
||||
if single_player && reason.is_none() {
|
||||
self.title = Some(title);
|
||||
self.capture(false);
|
||||
@@ -752,9 +757,6 @@ impl Game<'_> {
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
);
|
||||
if self.mods.running() {
|
||||
self.mods_chat(&text);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat.push((text, Instant::now()));
|
||||
r.chat_received += 1;
|
||||
@@ -1012,7 +1014,12 @@ impl Game<'_> {
|
||||
if self.remote.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some((id, command)) = self.ui.chat_screen.as_mut().and_then(|s| s.take_request())
|
||||
let Some((id, command)) = self
|
||||
.ui
|
||||
.chat_screen
|
||||
.as_mut()
|
||||
.and_then(|s| s.take_request())
|
||||
.or_else(|| self.command_block_suggestion_request())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -1052,7 +1059,8 @@ impl Game<'_> {
|
||||
})();
|
||||
if let Ok((id, start, length, list)) = decoded {
|
||||
let range = (start.max(0) as usize, length.max(0) as usize);
|
||||
self.chat_server_suggestions(id, range, list);
|
||||
self.chat_server_suggestions(id, range, list.clone());
|
||||
self.command_block_server_suggestions(id, range, list);
|
||||
}
|
||||
}
|
||||
fn set_registries(&mut self, registries: Arc<Dynamic>) {
|
||||
@@ -1247,10 +1255,15 @@ impl Game<'_> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let command_block = matches!(loaded, BlockEntity::CommandBlock(_));
|
||||
self.world.block_entities.insert(key, loaded);
|
||||
self.update_spawner_display(key);
|
||||
// The section is drawn again with what the block entity shows.
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let _ = self.world.set(pos[0], pos[1], pos[2], state);
|
||||
if command_block {
|
||||
self.command_block_data_arrived(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `handleAnimate`: a player wakes up, or an entity gets the critical
|
||||
@@ -2875,7 +2888,6 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.commands = Some(tree);
|
||||
}
|
||||
self.merge_remote_tree();
|
||||
}
|
||||
C::Cooldown(p) => {
|
||||
// ClientPacketListener.handleItemCooldown: the server starts
|
||||
@@ -3725,8 +3737,8 @@ impl Game<'_> {
|
||||
pub(super) fn tick_remote(&mut self) -> Result<()> {
|
||||
self.tick_remote_level()?;
|
||||
// Client mods tick on any server, single player's own included.
|
||||
self.tick_mods();
|
||||
self.native_tick();
|
||||
self.tell_native_now();
|
||||
// Minecraft.tick sends this even when the player cannot tick yet,
|
||||
// including while waiting for chunks. Keep it outside early returns.
|
||||
if self.remote.as_ref().is_some_and(|r| r.joined) {
|
||||
@@ -3802,6 +3814,7 @@ impl Game<'_> {
|
||||
// and the moving pistons a block event's client-side moveBlocks made
|
||||
// (their progress advances between the server's block updates).
|
||||
self.tick_block_entity_anims();
|
||||
self.tick_remote_spawners();
|
||||
self.client_tick_beacons();
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.client_tick_block_entities(level, ticks);
|
||||
@@ -3820,6 +3833,7 @@ impl Game<'_> {
|
||||
self.tick_subtitles();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
let pushed = self.remote_pushes();
|
||||
let sprint_window = self.settings.sprint_window;
|
||||
let Some(player) = self.player.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -3918,7 +3932,7 @@ impl Game<'_> {
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.controlled_simulation_tick(keys)?;
|
||||
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
|
||||
} else {
|
||||
// LivingEntity.baseTick counts hurtTime down for a dead or riding
|
||||
// player too (controlled_simulation_tick does it otherwise): left
|
||||
@@ -5794,7 +5808,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
|
||||
/// the action; swing only if a nonempty stack was removed.
|
||||
fn remote_drop(&mut self, all: bool) {
|
||||
pub(super) fn remote_drop(&mut self, all: bool) {
|
||||
let dropped =
|
||||
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
|
||||
self.send(S::PlayerAction(play::PlayerAction {
|
||||
@@ -5883,22 +5897,23 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.map(|&(slot, button, input)| (slot, button, input, Vec::new(), self.carried.clone()))
|
||||
.collect();
|
||||
let mut threw = false;
|
||||
// The engine's slot indices are the menu's own, as on the network.
|
||||
self.menu_session(|m, env, _| {
|
||||
let mut predicted = Vec::with_capacity(clicks.len());
|
||||
for &(slot, button, input) in &clicks {
|
||||
let before = menu_items(m);
|
||||
if m.click(env, i32::from(slot), button, input).is_err() {
|
||||
// The server throws on it too: nothing is predicted.
|
||||
return Some(false);
|
||||
}
|
||||
let changed = changed_slots(&before, m);
|
||||
predicted.push((slot, button, input, changed, m.carried.clone()));
|
||||
}
|
||||
let Some((predicted, dropped)) = predict_clicks(m, env, &clicks) else {
|
||||
// The server throws on it too: nothing is predicted.
|
||||
return Some(false);
|
||||
};
|
||||
sent = predicted;
|
||||
threw = dropped;
|
||||
Some(true)
|
||||
});
|
||||
self.send_clicks(container, sent);
|
||||
// LivingEntity.drop(.., Prediction.PREDICTED): the client swings its own
|
||||
// main hand; the server shows the swing only to the other players.
|
||||
if threw {
|
||||
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A Shift double click on a server (`AbstractContainerScreen.mouseReleased`):
|
||||
@@ -6447,8 +6462,9 @@ impl Game<'_> {
|
||||
.collect::<Vec<_>>(),
|
||||
// The player's own model exists (third person and the inventory draw it).
|
||||
"player_model": self.player_model.is_some(),
|
||||
// The world view draws it now (third person, or lying in a bed).
|
||||
"own_body": self.player_model_shown(),
|
||||
// The world view draws its body now (third person, or lying in a bed;
|
||||
// not while invisible).
|
||||
"own_body": self.player_model_shown() && self.own_body_visible(),
|
||||
"text_display_vertices": self.text_display_vertices,
|
||||
"text_displays": self.text_displays.len(),
|
||||
"health": r.health.0,
|
||||
@@ -6550,6 +6566,30 @@ fn creative_sync_slot(i: usize) -> i16 {
|
||||
}) as i16
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.handleContainerInput` for each click on the engine's
|
||||
/// copy of the menu: the click with the slots it changed and the carried
|
||||
/// stack after it, and whether a click dropped a stack. Every drop of
|
||||
/// `AbstractContainerMenu.doClick` (a THROW, a click outside the window, a
|
||||
/// swap or a result move with no room left) is `Prediction.PREDICTED`.
|
||||
/// None when the engine refuses a click.
|
||||
fn predict_clicks(
|
||||
m: &mut horizon_client::menu::Menu,
|
||||
env: &horizon_client::menu::Env,
|
||||
clicks: &[(i16, i32, Input)],
|
||||
) -> Option<(Vec<Predicted>, bool)> {
|
||||
let mut predicted = Vec::with_capacity(clicks.len());
|
||||
let mut dropped = false;
|
||||
for &(slot, button, input) in clicks {
|
||||
let before = menu_items(m);
|
||||
let thrown = m.dropped.len();
|
||||
m.click(env, i32::from(slot), button, input).ok()?;
|
||||
dropped |= m.dropped.len() > thrown;
|
||||
let changed = changed_slots(&before, m);
|
||||
predicted.push((slot, button, input, changed, m.carried.clone()));
|
||||
}
|
||||
Some((predicted, dropped))
|
||||
}
|
||||
|
||||
fn menu_items(m: &horizon_client::menu::Menu) -> Vec<Option<ItemStack>> {
|
||||
(0..m.slot_count()).map(|i| m.slot(i).cloned()).collect()
|
||||
}
|
||||
@@ -6845,6 +6885,66 @@ impl Remote {
|
||||
mod tests {
|
||||
use super::Game;
|
||||
|
||||
/// Q over a slot of the inventory on a server (#3357): the predicted THROW
|
||||
/// drops the stack, so the player's hand swings, as `LivingEntity.drop`
|
||||
/// does for `Prediction.PREDICTED`; a plain pick up drops nothing.
|
||||
#[test]
|
||||
fn a_predicted_throw_from_a_slot_swings_the_hand() {
|
||||
use horizon_client::{
|
||||
banner::Banners,
|
||||
brewing::Brewing,
|
||||
crafting::Recipes,
|
||||
creative::Names,
|
||||
enchant::Enchantments,
|
||||
items::{ItemStack, Items},
|
||||
menu::{Env, Input, Kind, Menu},
|
||||
stations::StationRecipes,
|
||||
};
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let items = Items::catalog(&root).unwrap();
|
||||
let recipes = Recipes::load(&root, &items).unwrap();
|
||||
let brewing = Brewing::load(&root, &items);
|
||||
let banners = Banners::load(&root);
|
||||
let station_recipes = StationRecipes::load(&root, &items);
|
||||
let enchantments = Enchantments::default();
|
||||
let names = Names::default();
|
||||
let env = Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
};
|
||||
let mut menu = Menu::new(Kind::Inventory);
|
||||
let cobblestone = ItemStack {
|
||||
item: "minecraft:cobblestone".into(),
|
||||
count: 16,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
// The first hotbar slot of InventoryMenu.
|
||||
menu.set_slot(&env, 36, Some(cobblestone));
|
||||
let (sent, threw) =
|
||||
super::predict_clicks(&mut menu, &env, &[(36, 0, Input::Throw)]).unwrap();
|
||||
assert!(threw, "Q over a slot drops one and swings");
|
||||
assert_eq!(menu.slot(36).map(|s| s.count), Some(15));
|
||||
assert_eq!(sent.len(), 1);
|
||||
let (_, threw) = super::predict_clicks(&mut menu, &env, &[(36, 0, Input::Pickup)]).unwrap();
|
||||
assert!(!threw, "picking the stack up drops nothing");
|
||||
let (_, threw) =
|
||||
super::predict_clicks(&mut menu, &env, &[(-999, 1, Input::Pickup)]).unwrap();
|
||||
assert!(threw, "one of the carried stack outside the window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_preview_checks_obstruction_without_changing_the_level() {
|
||||
use super::DryLevel;
|
||||
|
||||
@@ -135,6 +135,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
match (index, value) {
|
||||
(0, Value::Byte(flags)) => f.invisible = (*flags as u8 & 0x20) != 0,
|
||||
(8, Value::Id(d)) => f.facing = (*d).clamp(0, 5) as u8,
|
||||
(10, Value::Int(r)) => f.rotation = r.rem_euclid(8) as u8,
|
||||
_ => {}
|
||||
|
||||
@@ -22,8 +22,7 @@ impl Screen {
|
||||
| Self::Friends
|
||||
| Self::ServerLinks
|
||||
| Self::ConfirmLink
|
||||
| Self::DialogWarning
|
||||
| Self::Mod => true,
|
||||
| Self::DialogWarning => true,
|
||||
Self::Playing
|
||||
| Self::GameModeSwitcher
|
||||
| Self::Chat
|
||||
|
||||
@@ -1,576 +0,0 @@
|
||||
//! Server mods in the game (`docs/mods.md`, "Server mods"): the game that
|
||||
//! simulates the world (in-frame single player, the server thread, a LAN
|
||||
//! host, the dedicated server) runs them. At the end of each tick it tells
|
||||
//! them who joined and left, then what players said, broke and placed since
|
||||
//! the last one, then ticks them; a player's command reaches them at once.
|
||||
//! Their lines go to the players they name. With no server mod on, each hook
|
||||
//! here is one check.
|
||||
use super::{Game, lan::LanHost};
|
||||
#[cfg(not(feature = "mods"))]
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
/// Who did something a server mod hears about, resolved at the tick's end.
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(super) enum Who {
|
||||
/// The window's own player, in-frame.
|
||||
Local,
|
||||
/// A connection's player (the server thread's owner is one).
|
||||
Conn(u32),
|
||||
Uuid(u128),
|
||||
}
|
||||
|
||||
/// What players did during a tick, for the server mods at its end.
|
||||
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
|
||||
pub(super) enum ModEvent {
|
||||
Chat(Who, String),
|
||||
Block {
|
||||
who: Who,
|
||||
pos: [i32; 3],
|
||||
state: String,
|
||||
placed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_server_mods(&mut self) {}
|
||||
pub(super) fn mod_block_event(&mut self, _pos: [i32; 3], _state: BlockStateId, _placed: bool) {}
|
||||
pub(super) fn mod_player_chat(&mut self, _who: Who, _text: &str) {}
|
||||
pub(super) fn local_server_mod_command(&mut self, _line: &str) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn guest_server_mod_command(
|
||||
&mut self,
|
||||
_host: &mut LanHost,
|
||||
_conn: u32,
|
||||
_line: &str,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn apply_mod_block_writes(&mut self) {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
mod run {
|
||||
use super::{Game, LanHost, ModEvent, Who};
|
||||
use horizon_client::chunk_map::ChunkMap;
|
||||
use horizon_client::{
|
||||
command::args::GameMode as Mode,
|
||||
lan::data::component_nbt,
|
||||
text::{self, Text},
|
||||
};
|
||||
use horizon_mods::{
|
||||
ArgumentKind, BlockPos, GameMode, Output, ServerCommand, ServerPlayer, WorldView,
|
||||
};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Blocks queued for levels that don't tick (another dimension, in-frame)
|
||||
/// are kept up to this many; the oldest go first.
|
||||
const MAX_QUEUED_WRITES: usize = 1 << 16;
|
||||
|
||||
/// Capability `world`'s view during a call: the levels' chunks, moved out
|
||||
/// of the game for the call and back after it (no copy).
|
||||
struct LentWorld {
|
||||
registry: &'static BlockRegistry,
|
||||
biomes: std::sync::Arc<horizon_client::biome::Biomes>,
|
||||
dims: Vec<(&'static str, ChunkMap)>,
|
||||
}
|
||||
impl WorldView for LentWorld {
|
||||
fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
|
||||
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
|
||||
let chunk = chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(state_string(self.registry, chunk.block(x & 15, y, z & 15)))
|
||||
}
|
||||
fn biome(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
|
||||
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
|
||||
let chunk = chunks.get(&(x >> 4, z >> 4))?;
|
||||
Some(self.biomes.get(chunk.biome(x, y, z)).name.clone())
|
||||
}
|
||||
fn has_dimension(&self, dimension: &str) -> bool {
|
||||
matches!(
|
||||
dimension,
|
||||
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
|
||||
)
|
||||
}
|
||||
fn check_state(&self, state: &str) -> Result<(), String> {
|
||||
crate::game::render_control::parse_state(self.registry, state)
|
||||
.map(drop)
|
||||
.map_err(|e| format!("{state}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Server mods' commands as a root of the commands report
|
||||
/// (`lan::commands`): a literal per command, then its arguments in a
|
||||
/// chain, each node runnable where only optional arguments follow.
|
||||
fn command_tree<'a>(commands: impl Iterator<Item = &'a ServerCommand>) -> Value {
|
||||
let mut root = serde_json::Map::new();
|
||||
for c in commands {
|
||||
let mut next: Option<(String, Value)> = None;
|
||||
for (i, a) in c.arguments.iter().enumerate().rev() {
|
||||
let executable = c.arguments.get(i + 1).is_none_or(|b| b.optional);
|
||||
let (parser, properties) = match a.kind {
|
||||
ArgumentKind::Word => ("brigadier:string", json!({"type": "word"})),
|
||||
ArgumentKind::Text => ("brigadier:string", json!({"type": "greedy"})),
|
||||
ArgumentKind::Integer((min, max)) => {
|
||||
let mut p = serde_json::Map::new();
|
||||
if let Some(min) = min {
|
||||
p.insert("min".into(), min.into());
|
||||
}
|
||||
if let Some(max) = max {
|
||||
p.insert("max".into(), max.into());
|
||||
}
|
||||
("brigadier:integer", Value::Object(p))
|
||||
}
|
||||
ArgumentKind::Number => ("brigadier:double", Value::Null),
|
||||
ArgumentKind::Boolean => ("brigadier:bool", Value::Null),
|
||||
ArgumentKind::Player => (
|
||||
"minecraft:entity",
|
||||
json!({"amount": "single", "type": "players"}),
|
||||
),
|
||||
ArgumentKind::BlockPos => ("minecraft:block_pos", Value::Null),
|
||||
};
|
||||
let mut node = json!({
|
||||
"type": "argument",
|
||||
"parser": parser,
|
||||
"properties": properties,
|
||||
"executable": executable,
|
||||
});
|
||||
if let Some((name, child)) = next.take() {
|
||||
node["children"] = json!({ name: child });
|
||||
}
|
||||
next = Some((a.name.clone(), node));
|
||||
}
|
||||
let executable = c.arguments.first().is_none_or(|a| a.optional);
|
||||
let mut literal = json!({"type": "literal", "executable": executable});
|
||||
if let Some((name, child)) = next {
|
||||
literal["children"] = json!({ name: child });
|
||||
}
|
||||
root.insert(c.name.clone(), literal);
|
||||
}
|
||||
if root.is_empty() {
|
||||
return Value::Null;
|
||||
}
|
||||
json!({"type": "root", "children": root})
|
||||
}
|
||||
|
||||
/// `minecraft:oak_log[axis=y]`.
|
||||
fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String {
|
||||
let Some(s) = registry.state(state) else {
|
||||
return "minecraft:air".into();
|
||||
};
|
||||
if s.properties.is_empty() {
|
||||
return s.name.clone();
|
||||
}
|
||||
let props: Vec<String> = s
|
||||
.properties
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect();
|
||||
format!("{}[{}]", s.name, props.join(","))
|
||||
}
|
||||
|
||||
/// `UUID.toString()`.
|
||||
fn uuid_string(u: u128) -> String {
|
||||
let h = format!("{u:032x}");
|
||||
format!(
|
||||
"{}-{}-{}-{}-{}",
|
||||
&h[..8],
|
||||
&h[8..12],
|
||||
&h[12..16],
|
||||
&h[16..20],
|
||||
&h[20..]
|
||||
)
|
||||
}
|
||||
|
||||
fn mode(id: i32) -> GameMode {
|
||||
match id {
|
||||
1 => GameMode::Creative,
|
||||
2 => GameMode::Adventure,
|
||||
3 => GameMode::Spectator,
|
||||
_ => GameMode::Survival,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players on this game's server: the window's own player
|
||||
/// in-frame, then every connection's. `host` is the hub when it is
|
||||
/// out of `self.lan`.
|
||||
pub(in crate::game) fn server_players(&self, host: Option<&LanHost>) -> Vec<ServerPlayer> {
|
||||
let mut out = Vec::new();
|
||||
if self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& let Some(p) = &self.player
|
||||
{
|
||||
out.push(ServerPlayer {
|
||||
id: uuid_string(self.player_uuid()),
|
||||
name: crate::game::chat::PLAYER_NAME.to_owned(),
|
||||
x: p.position.x,
|
||||
y: p.position.y,
|
||||
z: p.position.z,
|
||||
dimension: self.dim.id().to_owned(),
|
||||
game_mode: match self.mode {
|
||||
Mode::Survival => GameMode::Survival,
|
||||
Mode::Creative => GameMode::Creative,
|
||||
Mode::Adventure => GameMode::Adventure,
|
||||
Mode::Spectator => GameMode::Spectator,
|
||||
},
|
||||
health: p.health.health(),
|
||||
});
|
||||
}
|
||||
let guests = host
|
||||
.or(self.lan.as_ref())
|
||||
.into_iter()
|
||||
.flat_map(|h| &h.hub.guests);
|
||||
out.extend(guests.filter(|g| g.placed).map(|g| ServerPlayer {
|
||||
id: uuid_string(g.uuid),
|
||||
name: g.name.clone(),
|
||||
x: g.position[0],
|
||||
y: g.position[1],
|
||||
z: g.position[2],
|
||||
dimension: g.dimension.clone(),
|
||||
game_mode: mode(g.game_mode),
|
||||
health: g.health,
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether what players do now reaches server mods: one is running
|
||||
/// and this game simulates the world.
|
||||
fn hears_players(&self) -> bool {
|
||||
self.mods.server && self.remote.is_none()
|
||||
}
|
||||
|
||||
/// The tick's end: joins and leaves, the tick's events, the tick hook.
|
||||
pub(in crate::game) fn tick_server_mods(&mut self) {
|
||||
if !self.options.execution_role.is_client() {
|
||||
// A server has no frames to check its dev mods in.
|
||||
self.reload_dev_mods();
|
||||
}
|
||||
self.send_server_mod_commands();
|
||||
if !self.mods.server {
|
||||
return;
|
||||
}
|
||||
let players = self.server_players(None);
|
||||
let snapshot = self.mod_snapshot_with(players.clone());
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
self.lend_world();
|
||||
let before = std::mem::take(&mut self.mods.players);
|
||||
for p in before
|
||||
.iter()
|
||||
.filter(|p| !players.iter().any(|q| q.id == p.id))
|
||||
{
|
||||
self.mods.mods.player_left(p);
|
||||
}
|
||||
for p in players
|
||||
.iter()
|
||||
.filter(|p| !before.iter().any(|q| q.id == p.id))
|
||||
{
|
||||
self.mods.mods.player_joined(p);
|
||||
}
|
||||
for event in std::mem::take(&mut self.mods.events) {
|
||||
let who = |w: &Who| {
|
||||
let id = match w {
|
||||
Who::Local => uuid_string(self.player_uuid()),
|
||||
Who::Uuid(u) => uuid_string(*u),
|
||||
Who::Conn(c) => {
|
||||
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
|
||||
let g = guests.into_iter().find(|g| g.conn == *c);
|
||||
g.map_or_else(String::new, |g| uuid_string(g.uuid))
|
||||
}
|
||||
};
|
||||
players.iter().chain(&before).find(|p| p.id == id).cloned()
|
||||
};
|
||||
match event {
|
||||
ModEvent::Chat(w, text) => {
|
||||
if let Some(p) = who(&w) {
|
||||
self.mods.mods.player_chat(&p, &text);
|
||||
}
|
||||
}
|
||||
ModEvent::Block {
|
||||
who: w,
|
||||
pos,
|
||||
state,
|
||||
placed,
|
||||
} => {
|
||||
let Some(p) = who(&w) else { continue };
|
||||
let pos = BlockPos {
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
};
|
||||
if placed {
|
||||
self.mods.mods.block_placed(&p, pos, &state);
|
||||
} else {
|
||||
self.mods.mods.block_broken(&p, pos, &state);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mods.mods.server_tick(self.ticks);
|
||||
self.return_world();
|
||||
self.mods.players = players;
|
||||
self.show_mod_output();
|
||||
self.apply_mod_block_writes();
|
||||
}
|
||||
|
||||
/// Players' command trees list the running server mods' commands
|
||||
/// after vanilla's, whenever they change (or a hub opens).
|
||||
fn send_server_mod_commands(&mut self) {
|
||||
let key = self.mods.mods.generation();
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if host.hub.mod_commands_key() == Some(key) {
|
||||
return;
|
||||
}
|
||||
let tree = command_tree(self.mods.mods.server_commands());
|
||||
host.hub.set_mod_commands(key, tree);
|
||||
}
|
||||
|
||||
/// Lends the levels' chunks to capability `world` for the calls up to
|
||||
/// [`Game::return_world`], when a running server mod has it.
|
||||
fn lend_world(&mut self) {
|
||||
if !self.mods.world {
|
||||
return;
|
||||
}
|
||||
let mut dims = vec![(self.dim.id(), std::mem::take(&mut self.world.chunks))];
|
||||
for (dim, chunks) in self.levels.parked_chunks() {
|
||||
dims.push((dim.id(), std::mem::take(chunks)));
|
||||
}
|
||||
let registry = self.pack.assets.registry;
|
||||
let biomes = self.res.biomes().clone();
|
||||
self.mods.mods.lend_world(Box::new(LentWorld {
|
||||
registry,
|
||||
biomes,
|
||||
dims,
|
||||
}));
|
||||
}
|
||||
|
||||
/// Puts back the chunks [`Game::lend_world`] lent.
|
||||
fn return_world(&mut self) {
|
||||
let Some(view) = self.mods.mods.take_world() else {
|
||||
return;
|
||||
};
|
||||
let view: Box<dyn std::any::Any> = view;
|
||||
let Ok(lent) = view.downcast::<LentWorld>() else {
|
||||
return;
|
||||
};
|
||||
let mut dims = lent.dims;
|
||||
if let Some(i) = dims.iter().position(|(d, _)| *d == self.dim.id()) {
|
||||
self.world.chunks = dims.swap_remove(i).1;
|
||||
}
|
||||
for (dim, chunks) in self.levels.parked_chunks() {
|
||||
if let Some(i) = dims.iter().position(|(d, _)| *d == dim.id()) {
|
||||
*chunks = dims.swap_remove(i).1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the blocks server mods set in the level in place, in order,
|
||||
/// as `/setblock` does. Those of other dimensions wait for their level.
|
||||
pub(in crate::game) fn apply_mod_block_writes(&mut self) {
|
||||
if self.mods.block_writes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dim = self.dim.id();
|
||||
let (now, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.mods.block_writes)
|
||||
.into_iter()
|
||||
.partition(|(d, _)| d == dim);
|
||||
self.mods.block_writes = later;
|
||||
let registry = self.pack.assets.registry;
|
||||
for (_, b) in now {
|
||||
let state = match crate::game::render_control::parse_state(registry, &b.state) {
|
||||
Ok(state) => state,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: a server mod set {}: {e}", b.state);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.place_state([b.x, b.y, b.z], state, false) {
|
||||
log::debug!(
|
||||
"Horizon: a server mod's block at {} {} {}: {e:?}",
|
||||
b.x,
|
||||
b.y,
|
||||
b.z
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A player broke (or placed) `state` at `pos`: the player acting now.
|
||||
pub(in crate::game) fn mod_block_event(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
placed: bool,
|
||||
) {
|
||||
if !self.hears_players() {
|
||||
return;
|
||||
}
|
||||
let who = self.lan_acting.map_or(Who::Local, Who::Conn);
|
||||
let state = self.state_string(state);
|
||||
self.mods.events.push(ModEvent::Block {
|
||||
who,
|
||||
pos,
|
||||
state,
|
||||
placed,
|
||||
});
|
||||
}
|
||||
|
||||
/// A chat message a player sent.
|
||||
pub(in crate::game) fn mod_player_chat(&mut self, who: Who, text: &str) {
|
||||
if self.hears_players() {
|
||||
self.mods.events.push(ModEvent::Chat(who, text.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
/// `/line` typed by the window's own player in-frame: true when a
|
||||
/// server mod owns it.
|
||||
pub(in crate::game) fn local_server_mod_command(&mut self, line: &str) -> bool {
|
||||
if !self.hears_players() {
|
||||
return false;
|
||||
}
|
||||
let players = self.server_players(None);
|
||||
let Some(me) = players.first().cloned() else {
|
||||
return false;
|
||||
};
|
||||
let snapshot = self.mod_snapshot_with(players);
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
self.lend_world();
|
||||
let owned = self.mods.mods.server_command(&me, line);
|
||||
self.return_world();
|
||||
self.show_mod_output();
|
||||
self.apply_mod_block_writes();
|
||||
owned
|
||||
}
|
||||
|
||||
/// `/line` typed by a connection's player: true when a server mod
|
||||
/// owns it, whatever the player's permission level.
|
||||
pub(in crate::game) fn guest_server_mod_command(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
line: &str,
|
||||
) -> bool {
|
||||
if !self.hears_players() {
|
||||
return false;
|
||||
}
|
||||
let Some(g) = host.hub.guest(conn) else {
|
||||
return false;
|
||||
};
|
||||
let id = uuid_string(g.uuid);
|
||||
let players = self.server_players(Some(host));
|
||||
let Some(me) = players.iter().find(|p| p.id == id).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let snapshot = self.mod_snapshot_with(players);
|
||||
self.mods.mods.set_snapshot(snapshot);
|
||||
self.lend_world();
|
||||
let owned = self.mods.mods.server_command(&me, line);
|
||||
self.return_world();
|
||||
if !owned {
|
||||
return false;
|
||||
}
|
||||
self.route_mod_output(Some(host));
|
||||
self.apply_mod_block_writes();
|
||||
true
|
||||
}
|
||||
|
||||
/// Sends a server mod's line to the player with this id: the
|
||||
/// window's own chat for the local player, a system message for a
|
||||
/// connection's. `host` is the hub when it is out of `self.lan`.
|
||||
pub(in crate::game) fn tell_server_player(
|
||||
&mut self,
|
||||
host: Option<&mut LanHost>,
|
||||
id: &str,
|
||||
line: Text,
|
||||
) {
|
||||
let local = self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& self.player.is_some();
|
||||
if local && uuid_string(self.player_uuid()) == id {
|
||||
self.add_mod_chat_line(line);
|
||||
return;
|
||||
}
|
||||
let Some(host) = host.or(self.lan.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
let to = host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.placed && uuid_string(g.uuid) == id);
|
||||
if let Some(conn) = to.map(|g| g.conn) {
|
||||
host.hub.send_to(
|
||||
conn,
|
||||
&C::SystemChat(play::SystemChat {
|
||||
content: component_nbt(&line.component()),
|
||||
overlay: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a server mod's output: `Tell` and `Reply` to their player,
|
||||
/// `Broadcast` to everyone and the log.
|
||||
pub(in crate::game) fn route_server_output(
|
||||
&mut self,
|
||||
mut host: Option<&mut LanHost>,
|
||||
output: Output,
|
||||
) {
|
||||
let named = |name: &str, text: String| {
|
||||
let mut line = Text::literal(format!("[{name}] ")).with_color(text::GRAY);
|
||||
line.push(Text::literal(text));
|
||||
line
|
||||
};
|
||||
match output {
|
||||
Output::Tell { name, player, text } => {
|
||||
self.tell_server_player(host, &player, named(&name, text));
|
||||
}
|
||||
Output::Reply {
|
||||
player,
|
||||
text,
|
||||
error,
|
||||
} => {
|
||||
let line = Text::literal(text);
|
||||
let line = if error {
|
||||
line.with_color(text::RED)
|
||||
} else {
|
||||
line
|
||||
};
|
||||
self.tell_server_player(host, &player, line);
|
||||
}
|
||||
Output::Broadcast { name, text } => {
|
||||
let plain = format!("[{name}] {text}");
|
||||
if self.options.dedicated.is_some() {
|
||||
super::super::dedicated::log("INFO", &plain);
|
||||
} else {
|
||||
log::info!("{plain}");
|
||||
}
|
||||
for p in self.server_players(host.as_deref()) {
|
||||
let line = named(&name, text.clone());
|
||||
self.tell_server_player(host.as_deref_mut(), &p.id, line);
|
||||
}
|
||||
}
|
||||
Output::SetBlocks {
|
||||
name,
|
||||
dimension,
|
||||
blocks,
|
||||
} => {
|
||||
let queued = &mut self.mods.block_writes;
|
||||
queued.extend(blocks.into_iter().map(|b| (dimension.clone(), b)));
|
||||
if queued.len() > MAX_QUEUED_WRITES {
|
||||
let over = queued.len() - MAX_QUEUED_WRITES;
|
||||
queued.drain(..over);
|
||||
log::warn!(
|
||||
"Horizon: mod {name} set more blocks than wait for their level: {over} dropped"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use horizon_client::camera::CameraFluid;
|
||||
use horizon_client::gfx::pack_uniforms::PackInputs;
|
||||
use horizon_client::text::Text;
|
||||
use horizon_shaders::{BlockIds, Macros, Pack, Properties, dimension_folder, load_post};
|
||||
|
||||
use super::{Dim, Game};
|
||||
@@ -64,79 +65,87 @@ impl Game<'_> {
|
||||
{
|
||||
let name = name.as_str();
|
||||
let started = std::time::Instant::now();
|
||||
match Pack::open(&path) {
|
||||
Ok(pack) => {
|
||||
let macros = Macros::standard();
|
||||
let text = pack.read_text("shaders.properties").unwrap_or_default();
|
||||
let properties = Properties::parse(&text, ¯os);
|
||||
for note in &properties.notes {
|
||||
log::warn!(
|
||||
"shader pack {name}: shaders.properties:{}: {}",
|
||||
note.line,
|
||||
note.message
|
||||
);
|
||||
}
|
||||
let folder = dimension_folder(&pack, dimension);
|
||||
let inputs = renderer.pack_inputs();
|
||||
let mut post = load_post(&pack, &folder, &properties, ¯os, &inputs);
|
||||
let biomes = self.res.biomes();
|
||||
let names: Vec<&str> = biomes.list.iter().map(|b| b.name.as_str()).collect();
|
||||
post.custom.set_biomes(&names);
|
||||
let text = pack.read_text("block.properties").unwrap_or_default();
|
||||
let ids = BlockIds::parse(&Properties::parse(&text, ¯os));
|
||||
for note in &ids.notes {
|
||||
log::warn!("shader pack {name}: block.properties: {note}");
|
||||
}
|
||||
let text = pack.read_text("entity.properties");
|
||||
let entities = BlockIds::parse_kind(
|
||||
&Properties::parse(text.as_deref().unwrap_or_default(), ¯os),
|
||||
"entity",
|
||||
);
|
||||
let text = pack.read_text("item.properties");
|
||||
let items = BlockIds::parse_kind(
|
||||
&Properties::parse(text.as_deref().unwrap_or_default(), ¯os),
|
||||
"item",
|
||||
);
|
||||
self.pack_ids = Some(PackIds {
|
||||
items: items.plain(),
|
||||
missing_item: if text.is_some() { -1 } else { 0 },
|
||||
// OQ-21: on unless the pack turns it off.
|
||||
old_hand_light: properties
|
||||
.get("oldHandLight")
|
||||
.is_none_or(|v| v.trim() != "false"),
|
||||
entities: entities.plain(),
|
||||
missing_entity: if text.is_some() { 65535 } else { 0 },
|
||||
missing_block: if ids.is_empty() { 0 } else { 65535 },
|
||||
blocks: ids.clone(),
|
||||
block_states: RefCell::default(),
|
||||
});
|
||||
let started_ids = std::time::Instant::now();
|
||||
let table = block_id_table(&self.pack, &ids);
|
||||
log::info!(
|
||||
"shader pack {name}: mc_Entity for {} of {} block sprites in {} ms",
|
||||
table.chunks(2).filter(|p| p[0] != -1).count(),
|
||||
table.len() / 2,
|
||||
started_ids.elapsed().as_millis()
|
||||
);
|
||||
if let Some((programs, notes)) =
|
||||
renderer.set_shader_pack(dimension, Some(post), &table)
|
||||
{
|
||||
for note in notes {
|
||||
log::warn!("shader pack {name}: {note}");
|
||||
// A pack is not the game's own code: one that makes the shader compiler (or our
|
||||
// translation of it) panic is turned off instead of crashing every world load (#3525).
|
||||
let loaded = horizon_client::crash::contain(|| {
|
||||
match Pack::open(&path) {
|
||||
Ok(pack) => {
|
||||
let macros = Macros::standard();
|
||||
let text = pack.read_text("shaders.properties").unwrap_or_default();
|
||||
let properties = Properties::parse(&text, ¯os);
|
||||
for note in &properties.notes {
|
||||
log::warn!(
|
||||
"shader pack {name}: shaders.properties:{}: {}",
|
||||
note.line,
|
||||
note.message
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"shader pack {name}: folder `{folder}`, {} programs ({}) in {} ms",
|
||||
programs.len(),
|
||||
programs.join(" "),
|
||||
started.elapsed().as_millis()
|
||||
let folder = dimension_folder(&pack, dimension);
|
||||
let inputs = renderer.pack_inputs();
|
||||
let mut post = load_post(&pack, &folder, &properties, ¯os, &inputs);
|
||||
let biomes = self.res.biomes();
|
||||
let names: Vec<&str> =
|
||||
biomes.list.iter().map(|b| b.name.as_str()).collect();
|
||||
post.custom.set_biomes(&names);
|
||||
let text = pack.read_text("block.properties").unwrap_or_default();
|
||||
let ids = BlockIds::parse(&Properties::parse(&text, ¯os));
|
||||
for note in &ids.notes {
|
||||
log::warn!("shader pack {name}: block.properties: {note}");
|
||||
}
|
||||
let text = pack.read_text("entity.properties");
|
||||
let entities = BlockIds::parse_kind(
|
||||
&Properties::parse(text.as_deref().unwrap_or_default(), ¯os),
|
||||
"entity",
|
||||
);
|
||||
let text = pack.read_text("item.properties");
|
||||
let items = BlockIds::parse_kind(
|
||||
&Properties::parse(text.as_deref().unwrap_or_default(), ¯os),
|
||||
"item",
|
||||
);
|
||||
self.pack_ids = Some(PackIds {
|
||||
items: items.plain(),
|
||||
missing_item: if text.is_some() { -1 } else { 0 },
|
||||
// OQ-21: on unless the pack turns it off.
|
||||
old_hand_light: properties
|
||||
.get("oldHandLight")
|
||||
.is_none_or(|v| v.trim() != "false"),
|
||||
entities: entities.plain(),
|
||||
missing_entity: if text.is_some() { 65535 } else { 0 },
|
||||
missing_block: if ids.is_empty() { 0 } else { 65535 },
|
||||
blocks: ids.clone(),
|
||||
block_states: RefCell::default(),
|
||||
});
|
||||
let started_ids = std::time::Instant::now();
|
||||
let table = block_id_table(&self.pack, &ids);
|
||||
log::info!(
|
||||
"shader pack {name}: mc_Entity for {} of {} block sprites in {} ms",
|
||||
table.chunks(2).filter(|p| p[0] != -1).count(),
|
||||
table.len() / 2,
|
||||
started_ids.elapsed().as_millis()
|
||||
);
|
||||
if let Some((programs, notes)) =
|
||||
renderer.set_shader_pack(dimension, Some(post), &table)
|
||||
{
|
||||
for note in notes {
|
||||
log::warn!("shader pack {name}: {note}");
|
||||
}
|
||||
log::info!(
|
||||
"shader pack {name}: folder `{folder}`, {} programs ({}) in {} ms",
|
||||
programs.len(),
|
||||
programs.join(" "),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.pack_ids = None;
|
||||
log::warn!("shader pack {name}: {e}");
|
||||
renderer.set_shader_pack(dimension, None, &[]);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.pack_ids = None;
|
||||
log::warn!("shader pack {name}: {e}");
|
||||
renderer.set_shader_pack(dimension, None, &[]);
|
||||
}
|
||||
});
|
||||
if let Err(panic) = loaded {
|
||||
self.shader_pack_failed(name, dimension, &panic);
|
||||
}
|
||||
}
|
||||
let lerp = |(old, new): (f32, f32)| old + (new - old) * partial;
|
||||
@@ -175,6 +184,25 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns off the pack `name` after loading it panicked: nothing runs in its place, it is no
|
||||
/// longer the picked pack (so the next start does not load it again), and the chat says so.
|
||||
fn shader_pack_failed(&mut self, name: &str, dimension: i32, error: &str) {
|
||||
log::error!("shader pack {name}: turned off: {error}");
|
||||
self.pack_ids = None;
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
// Marks this dimension as loaded, without a pack, so it is not tried again.
|
||||
renderer.set_shader_pack(dimension, None, &[]);
|
||||
}
|
||||
if self.settings.horizon.shader_pack == name {
|
||||
self.settings.horizon.shader_pack.clear();
|
||||
self.settings.save(&Self::options_path());
|
||||
}
|
||||
self.system_message(Text::colored(
|
||||
format!("Shader pack {name} could not be loaded and was turned off"),
|
||||
0xFF5555,
|
||||
));
|
||||
}
|
||||
|
||||
/// `heldItemId(2)` and `heldBlockLightValue(2)`: main hand, then off hand (5.2, 5.3).
|
||||
fn held_for_pack(&mut self) -> ([i32; 2], [i32; 2]) {
|
||||
if self.pack_ids.is_none() {
|
||||
|
||||
@@ -407,9 +407,40 @@ impl Game<'_> {
|
||||
}
|
||||
// sendBlockUpdated(3) and the player's block_change game event.
|
||||
self.spawner_block_updated(key);
|
||||
self.update_spawner_display(key);
|
||||
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
|
||||
Some(true)
|
||||
}
|
||||
/// Ticks spawners and trial spawners near the player on a remote client (`tick_remote`).
|
||||
pub(super) fn tick_remote_spawners(&mut self) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let center = [
|
||||
p.position.x.floor() as i32,
|
||||
p.position.y.floor() as i32,
|
||||
p.position.z.floor() as i32,
|
||||
];
|
||||
let mut keys = Vec::new();
|
||||
let mut trial_keys = Vec::new();
|
||||
for (&pos, be) in &self.world.block_entities {
|
||||
let d = (pos.0 - center[0])
|
||||
.abs()
|
||||
.max((pos.1 - center[1]).abs())
|
||||
.max((pos.2 - center[2]).abs());
|
||||
if d <= 64 {
|
||||
match be {
|
||||
BlockEntity::Spawner(_) => keys.push(pos),
|
||||
BlockEntity::TrialSpawner(_) => trial_keys.push(pos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.client_tick_spawners(&keys);
|
||||
for key in trial_keys {
|
||||
self.client_tick_trial_spawner(key);
|
||||
}
|
||||
}
|
||||
/// `SpawnerRenderer` and `TrialSpawnerRenderer`: the next mob of every
|
||||
/// spawner near the camera, small and turning.
|
||||
pub(super) fn spawner_geometry(
|
||||
|
||||
@@ -76,6 +76,7 @@ impl StartupMenu {
|
||||
&self.root,
|
||||
);
|
||||
title.last_server = settings.file.string("lastServer");
|
||||
title.skip_multiplayer_warning = settings.file.bool("skipMultiplayerWarning");
|
||||
self.title = Some(title);
|
||||
}
|
||||
}
|
||||
@@ -204,6 +205,15 @@ impl StartupMenu {
|
||||
}
|
||||
}
|
||||
|
||||
/// A panic's message: its `&str` or `String` payload.
|
||||
fn panic_text(payload: &(dyn std::any::Any + Send)) -> &str {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
|
||||
.unwrap_or("Box<dyn Any>")
|
||||
}
|
||||
|
||||
/// Each generation has its own channel and progress. Pack changes wait for the
|
||||
/// old tail before starting another generation, including cache/global writes.
|
||||
pub(super) fn spawn_loader(
|
||||
@@ -251,20 +261,27 @@ pub(super) fn spawn_loader(
|
||||
|
||||
impl Loading {
|
||||
pub(super) fn poll_menu_resources(&mut self) -> Result<()> {
|
||||
if self.loaded.is_none() {
|
||||
// Once `Done` arrived the generation is over: the caller may have taken
|
||||
// its result, and the finished loader's channel is closed.
|
||||
if self.loaded.is_none() && !self.delivered {
|
||||
loop {
|
||||
match self.receiver.try_recv() {
|
||||
Ok(LoaderMsg::Ui(gui, root)) => self.set_ui(gui, root),
|
||||
Ok(LoaderMsg::Done(result)) => {
|
||||
self.loaded = Some(result);
|
||||
self.delivered = true;
|
||||
break;
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
return Err("resource loader stopped before finishing".into());
|
||||
// The loader sends `Done` before it returns: it panicked.
|
||||
let reason = match self.handle.take().map(JoinHandle::join) {
|
||||
Some(Err(payload)) => format!(": {}", panic_text(payload.as_ref())),
|
||||
_ => String::new(),
|
||||
};
|
||||
return Err(
|
||||
format!("resource loader stopped before finishing{reason}").into()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,12 +321,11 @@ impl Loading {
|
||||
}
|
||||
|
||||
fn save_menu_address(&mut self) {
|
||||
if self
|
||||
.menu
|
||||
.title
|
||||
.as_ref()
|
||||
.is_some_and(|title| title.last_server != self.settings.file.string("lastServer"))
|
||||
{
|
||||
if self.menu.title.as_ref().is_some_and(|title| {
|
||||
title.last_server != self.settings.file.string("lastServer")
|
||||
|| title.skip_multiplayer_warning
|
||||
!= self.settings.file.bool("skipMultiplayerWarning")
|
||||
}) {
|
||||
self.save_menu_settings();
|
||||
}
|
||||
}
|
||||
@@ -320,6 +336,10 @@ impl Loading {
|
||||
"lastServer",
|
||||
horizon_client::options_file::V::Str(title.last_server.clone()),
|
||||
);
|
||||
self.settings.file.set(
|
||||
"skipMultiplayerWarning",
|
||||
horizon_client::options_file::V::Bool(title.skip_multiplayer_warning),
|
||||
);
|
||||
}
|
||||
self.settings.save(&Game::options_path());
|
||||
}
|
||||
@@ -405,7 +425,7 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
// Mods' settings pages open only in a running game.
|
||||
Event::ModSettings(_) | Event::NativeSettings(_) | Event::NativeButton(..) => {}
|
||||
Event::NativeSettings(_) | Event::NativeButton(..) => {}
|
||||
// The startup menu runs before the game opens a control socket.
|
||||
Event::ButtonClick | Event::CopyAgentDetails => {}
|
||||
}
|
||||
@@ -678,8 +698,11 @@ impl Loading {
|
||||
}
|
||||
}
|
||||
self.loaded = None;
|
||||
self.delivered = false;
|
||||
if let Some(handle) = self.handle.take() {
|
||||
handle.join().map_err(|_| "resource loader panicked")?;
|
||||
handle.join().map_err(|payload| {
|
||||
format!("resource loader panicked: {}", panic_text(payload.as_ref()))
|
||||
})?;
|
||||
}
|
||||
self.progress = Progress::new();
|
||||
let options = self.options.as_ref().ok_or("loading already finished")?;
|
||||
@@ -726,6 +749,7 @@ mod tests {
|
||||
progress: Progress::new(),
|
||||
receiver,
|
||||
loaded: None,
|
||||
delivered: false,
|
||||
handle: None,
|
||||
shown: false,
|
||||
menu: StartupMenu::default(),
|
||||
@@ -803,6 +827,47 @@ mod tests {
|
||||
assert!(menu.pending.take().is_none(), "an entry is consumed once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_generation_keeps_its_error_after_the_loader_ends() {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let worker = std::thread::spawn(move || {
|
||||
sender
|
||||
.send(LoaderMsg::Done(Err("missing facts.json".into())))
|
||||
.unwrap();
|
||||
});
|
||||
let mut loading = loading(receiver);
|
||||
loading.handle = Some(worker);
|
||||
while loading.loaded.is_none() {
|
||||
loading.poll_menu_resources().unwrap();
|
||||
}
|
||||
// App::about_to_wait takes the result to report it; the loop may poll
|
||||
// once more before it exits, with the loader gone.
|
||||
assert!(matches!(loading.loaded.take(), Some(Err(e)) if e == "missing facts.json"));
|
||||
while !loading.handle.as_ref().is_none_or(JoinHandle::is_finished) {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
loading.poll_menu_resources().unwrap();
|
||||
assert!(loading.loaded.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_loader_reports_its_panic() {
|
||||
let (sender, receiver) = std::sync::mpsc::channel::<LoaderMsg>();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let _sender = sender;
|
||||
panic!("atlas has no fonts");
|
||||
});
|
||||
while !worker.is_finished() {
|
||||
std::thread::yield_now();
|
||||
}
|
||||
let mut loading = loading(receiver);
|
||||
loading.handle = Some(worker);
|
||||
assert_eq!(
|
||||
loading.poll_menu_resources().unwrap_err().to_string(),
|
||||
"resource loader stopped before finishing: atlas has no fonts"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_core_worker_is_reported_instead_of_waiting_forever() {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
|
||||
@@ -556,11 +556,14 @@ pub(super) fn submit(
|
||||
} else {
|
||||
"entity/shield/shield_base_nopattern"
|
||||
};
|
||||
let foil = if has_foil && !has_patterns {
|
||||
Foil::Standard
|
||||
} else {
|
||||
Foil::None
|
||||
};
|
||||
// Without patterns: `entitySolidGlint`. With them, vanilla draws
|
||||
// `patternedShieldGlint` last, the glint alone over the finished
|
||||
// plate. A foil quad here is its texture with the glint over it
|
||||
// (the GUI adds the glint masked by the layer's alpha, the world
|
||||
// shader composites it), so every layer carries the foil: each
|
||||
// pixel ends with the glint once, over the layers below it.
|
||||
// Redrawing the base with the foil would cover the patterns.
|
||||
let foil = if has_foil { Foil::Standard } else { Foil::None };
|
||||
part("shield", &none, base, white, foil);
|
||||
if has_patterns {
|
||||
part(
|
||||
@@ -568,17 +571,13 @@ pub(super) fn submit(
|
||||
&none,
|
||||
"entity/shield/base",
|
||||
dye(base_color.unwrap_or("white")),
|
||||
Foil::None,
|
||||
foil,
|
||||
);
|
||||
for (pattern, color) in patterns.into_iter().take(16) {
|
||||
if let Some(t) = pattern_texture(host, false, &pattern) {
|
||||
part("shield", &none, &t, dye(&color), Foil::None);
|
||||
part("shield", &none, &t, dye(&color), foil);
|
||||
}
|
||||
}
|
||||
if has_foil {
|
||||
// `patternedShieldGlint`: the glint alone over the model.
|
||||
part("shield", &none, base, white, Foil::Standard);
|
||||
}
|
||||
}
|
||||
}
|
||||
SpecialKind::DecoratedPot => {
|
||||
@@ -688,6 +687,105 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `ShieldSpecialRenderer.submit` of an enchanted shield with a banner:
|
||||
/// the glint goes over the patterns, and nothing covers them after.
|
||||
#[test]
|
||||
fn an_enchanted_banner_shield_keeps_its_patterns_under_the_glint() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-shield-glint-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("items.json"), "{}").unwrap();
|
||||
std::fs::write(
|
||||
root.join("entity-models.json"),
|
||||
serde_json::json!({"minecraft:shield#main": {
|
||||
"pose": [0, 0, 0, 0, 0, 0, 1, 1, 1],
|
||||
"polygons": [[[0, 0, 0, 0, 0], [1, 0, 0, 1, 0],
|
||||
[1, 1, 0, 1, 1], [0, 1, 0, 0, 1]]],
|
||||
"children": {}
|
||||
}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let library = crate::model_library::ParsedModels::default().into_item_library();
|
||||
let renderer = ItemRenderer::load_with_library(&root, library).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let textures = [
|
||||
"entity/shield/shield_base",
|
||||
"entity/shield/shield_base_nopattern",
|
||||
"entity/shield/base",
|
||||
"entity/shield/stripe_bottom",
|
||||
];
|
||||
// A sprite per texture, told apart by its u0.
|
||||
let sprites: HashMap<String, Rect> = textures
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
let u0 = i as f32 / 8.0;
|
||||
let rect = Rect {
|
||||
u0,
|
||||
v0: 0.0,
|
||||
u1: u0 + 0.1,
|
||||
v1: 0.1,
|
||||
w: 16,
|
||||
h: 16,
|
||||
};
|
||||
((*t).to_owned(), rect)
|
||||
})
|
||||
.collect();
|
||||
let drawn = |argument: Value, has_foil: bool| {
|
||||
let mut quads: Vec<ItemQuad> = Vec::new();
|
||||
submit(
|
||||
&renderer,
|
||||
&SpecialModel {
|
||||
kind: SpecialKind::Shield,
|
||||
},
|
||||
&argument,
|
||||
has_foil,
|
||||
Mat4::IDENTITY,
|
||||
&sprites,
|
||||
&super::super::NoHost,
|
||||
&mut quads,
|
||||
);
|
||||
quads
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let texture = textures
|
||||
.iter()
|
||||
.find(|t| sprites[**t].u0 == q.uv[0][0].min(q.uv[1][0]))
|
||||
.unwrap();
|
||||
(*texture, q.foil)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let banner = serde_json::json!({
|
||||
"minecraft:base_color": "red",
|
||||
"minecraft:banner_patterns": [{
|
||||
"pattern": {"asset_id": "minecraft:stripe_bottom",
|
||||
"translation_key": "block.minecraft.banner.stripe_bottom"},
|
||||
"color": "blue"
|
||||
}]
|
||||
});
|
||||
assert_eq!(
|
||||
drawn(banner.clone(), true),
|
||||
[
|
||||
("entity/shield/shield_base", Foil::Standard),
|
||||
("entity/shield/base", Foil::Standard),
|
||||
("entity/shield/stripe_bottom", Foil::Standard),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
drawn(banner, false),
|
||||
[
|
||||
("entity/shield/shield_base", Foil::None),
|
||||
("entity/shield/base", Foil::None),
|
||||
("entity/shield/stripe_bottom", Foil::None),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
drawn(Value::Null, true),
|
||||
[("entity/shield/shield_base_nopattern", Foil::Standard)]
|
||||
);
|
||||
}
|
||||
/// `DecoratedPotRenderer` draws every part with `RenderTypes.entitySolid`,
|
||||
/// whose pipeline culls back faces: the pot's flat `top` and `bottom`
|
||||
/// planes would otherwise show both sides and z-fight in the hand too.
|
||||
|
||||
@@ -14,8 +14,8 @@ pub use horizon_client_render::{
|
||||
mesher, minimap, mipmap, mod_list, model_library, models, options_file, options_fixes,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, providers, screen_draw,
|
||||
screen_effects, sculk, settings, shader_pack_list, signs, skins, sound_engine, sound_events,
|
||||
spans, sprites, streaming, tab_list, telemetry, testgen, title_edition, trims, ui, vault,
|
||||
weather, widgets, world, worldborder, zip,
|
||||
spans, split_layout, sprites, streaming, tab_list, telemetry, testgen, title_edition, trims,
|
||||
ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -74,7 +74,6 @@ pub mod merchant;
|
||||
pub mod mobs;
|
||||
pub mod mod_content;
|
||||
pub mod motion;
|
||||
pub mod moved_extras;
|
||||
pub mod multiplayer;
|
||||
pub mod music;
|
||||
pub mod net;
|
||||
|
||||
@@ -734,6 +734,9 @@ pub fn anger_left(anger_end: i64, game_time: i64) -> i32 {
|
||||
/// Species-specific persistent state (variants, colours, taming, ageing...).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Extra {
|
||||
/// The local player's `minecraft:scale`, which scales the whole model
|
||||
/// (`LivingEntityRenderState.scale`); 0 is the default, 1.
|
||||
pub model_scale: f32,
|
||||
/// Players: crouching and riding (HumanoidModel's poses).
|
||||
pub crouching: bool,
|
||||
/// A player's or mannequin's synced pose (`Entity.DATA_POSE`), which
|
||||
@@ -2446,6 +2449,7 @@ impl Mob {
|
||||
self.invisible || self.has_effect("minecraft:invisibility")
|
||||
}
|
||||
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
|
||||
let effect = horizon_client_core::potions::namespaced(effect);
|
||||
if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) {
|
||||
// MobEffectInstance.update: a stronger effect replaces with its
|
||||
// own duration, an equal one only extends it; a weaker one is
|
||||
|
||||
@@ -475,7 +475,14 @@ pub fn tick(
|
||||
if m.species() == Species::ZombieVillager {
|
||||
// ZombieVillager.tick counts the cure down before super.tick, with
|
||||
// or without AI (NoAI only skips serverAiStep).
|
||||
super::zombie_villager::step(m, &mut Cx { w, events });
|
||||
super::zombie_villager::step(
|
||||
m,
|
||||
&mut Cx {
|
||||
w,
|
||||
lr: level_random,
|
||||
events,
|
||||
},
|
||||
);
|
||||
}
|
||||
super::warden::pre_tick(m, w, events);
|
||||
if m.species() == Species::Strider {
|
||||
@@ -1205,7 +1212,11 @@ fn server_ai_step(
|
||||
let mut targets = std::mem::take(&mut m.ai.targets);
|
||||
let mut goals = std::mem::take(&mut m.ai.goals);
|
||||
{
|
||||
let mut cx = Cx { w, events };
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: level_random,
|
||||
events,
|
||||
};
|
||||
if id_based % 2 != 0 && tick_count > 1 {
|
||||
targets.tick_running(m, &mut cx, false);
|
||||
goals.tick_running(m, &mut cx, false);
|
||||
@@ -1246,7 +1257,11 @@ fn server_ai_step(
|
||||
super::animal_brain::tick(m, w, level_random, events);
|
||||
}
|
||||
{
|
||||
let mut cx = Cx { w, events };
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: level_random,
|
||||
events,
|
||||
};
|
||||
custom_server_ai_step(m, &mut cx);
|
||||
}
|
||||
if frozen {
|
||||
@@ -2287,3 +2302,45 @@ mod frog_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod poison_effect_tests {
|
||||
use super::*;
|
||||
use crate::mobs::MobTypes;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn poison_effect_damages_living_mobs_but_not_undead() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-types.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = MobTypes::load(&root).unwrap();
|
||||
|
||||
// Pig is not undead: should take damage from poison.
|
||||
let pig_kind = Arc::new(data.types.get("minecraft:pig").unwrap().clone());
|
||||
let mut pig = Mob::new(1, pig_kind, Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
pig.add_effect("poison", 100, 0);
|
||||
assert_eq!(pig.extra.effects[0].0, "minecraft:poison");
|
||||
let initial_pig_health = pig.health;
|
||||
tick_effects(&mut pig);
|
||||
assert_eq!(
|
||||
pig.health,
|
||||
initial_pig_health - 1.0,
|
||||
"living mob should take 1.0 damage from poison"
|
||||
);
|
||||
|
||||
// Zombie is undead: immune to poison damage.
|
||||
let zombie_kind = Arc::new(data.types.get("minecraft:zombie").unwrap().clone());
|
||||
let mut zombie = Mob::new(2, zombie_kind, Vec3::new(0.0, 4.0, 0.0), 0.0);
|
||||
zombie.add_effect("poison", 100, 0);
|
||||
let initial_zombie_health = zombie.health;
|
||||
tick_effects(&mut zombie);
|
||||
assert_eq!(
|
||||
zombie.health, initial_zombie_health,
|
||||
"undead mobs are immune to poison"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2396,6 +2396,7 @@ fn axolotl_action(m: &mut Mob, cx: &mut Bx, action: AxolotlAction, until: &mut i
|
||||
target,
|
||||
&mut super::goals::Cx {
|
||||
w: cx.w,
|
||||
lr: cx.lr,
|
||||
events: cx.events,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -103,8 +103,15 @@ pub(super) fn dangerous_block(name: &str) -> bool {
|
||||
| "minecraft:powder_snow"
|
||||
)
|
||||
}
|
||||
/// `LongJumpUtil.calculateJumpVectorForAngle` without the collision check.
|
||||
fn jump_vector(m: &Mob, target: Vec3, max_velocity: f32, angle: i32) -> Option<Vec3> {
|
||||
/// `LongJumpUtil.calculateJumpVectorForAngle`; with a world, the arc is
|
||||
/// checked for collisions (`checkCollision`).
|
||||
pub(super) fn jump_vector(
|
||||
m: &Mob,
|
||||
target: Vec3,
|
||||
max_velocity: f32,
|
||||
angle: i32,
|
||||
collide: Option<&MobWorld>,
|
||||
) -> Option<Vec3> {
|
||||
let pos = m.position;
|
||||
let plane = Vec3::new(target.x - pos.x, 0.0, target.z - pos.z)
|
||||
.normalize()
|
||||
@@ -130,8 +137,41 @@ fn jump_vector(m: &Mob, target: Vec3, max_velocity: f32, angle: i32) -> Option<V
|
||||
return None;
|
||||
}
|
||||
let (v0r, v0y) = (v0 * cos, v0 * sin);
|
||||
if let Some(w) = collide {
|
||||
// `ceil(r / v0r) * 2` points along the arc, each step clear of blocks.
|
||||
let samples = (r / v0r).ceil() as i32 * 2;
|
||||
let (sin_xz, cos_xz) = (jmath::sin(xz), jmath::cos(xz));
|
||||
let mut ri = 0.0;
|
||||
let mut previous: Option<Vec3> = None;
|
||||
for _ in 0..samples - 1 {
|
||||
ri += r / f64::from(samples);
|
||||
let yi = sin / cos * ri - jmath::pow(ri, 2.0) * g / (2.0 * v0sq * jmath::pow(cos, 2.0));
|
||||
let p = Vec3::new(pos.x + ri * cos_xz, pos.y + yi, pos.z + ri * sin_xz);
|
||||
if previous.is_some_and(|q| !clear_transition(m, w, q, p)) {
|
||||
return None;
|
||||
}
|
||||
previous = Some(p);
|
||||
}
|
||||
}
|
||||
Some(Vec3::new(v0r * jmath::cos(xz), v0y, v0r * jmath::sin(xz)).scale(f64::from(0.95_f32)))
|
||||
}
|
||||
/// `LongJumpUtil.isClearTransition`: the box fits at points no further apart
|
||||
/// than nine tenths of its smaller side.
|
||||
fn clear_transition(m: &Mob, w: &MobWorld, from: Vec3, to: Vec3) -> bool {
|
||||
let d = to.subtract(from);
|
||||
let min = f64::from(m.width().min(m.height()));
|
||||
let checks = (d.length() / min).ceil() as i32;
|
||||
let step = d.normalize().scale(min * f64::from(0.9_f32));
|
||||
let mut p = from;
|
||||
(0..checks).all(|i| {
|
||||
p = if i == checks - 1 {
|
||||
to
|
||||
} else {
|
||||
p.add_vector(step)
|
||||
};
|
||||
w.no_collision(m.bounds_at(p))
|
||||
})
|
||||
}
|
||||
/// `LongJump.calculateOptimalJumpVector`: the allowed angles shuffled, the
|
||||
/// first that reaches the target.
|
||||
pub(super) fn optimal_jump_vector(m: &mut Mob, target: Vec3) -> Option<Vec3> {
|
||||
@@ -143,7 +183,7 @@ pub(super) fn optimal_jump_vector(m: &mut Mob, target: Vec3) -> Option<Vec3> {
|
||||
let max = 0.058_333_334_f32 * m.attrs.follow_range as f32;
|
||||
let v = angles
|
||||
.iter()
|
||||
.find_map(|&a| jump_vector(m, target, max, a))?;
|
||||
.find_map(|&a| jump_vector(m, target, max, a, None))?;
|
||||
if m.has_effect("minecraft:jump_boost") {
|
||||
let amp = m
|
||||
.extra
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Frogs (`Frog`, `FrogAi`): the `Croak` and `ShootTongue` behaviours as
|
||||
//! goals, the poses they set and the client's animation states.
|
||||
//! Frogs (`Frog`, `FrogAi`): the `Croak`, `ShootTongue` and long jump
|
||||
//! behaviours as goals, the poses they set and the client's animation states.
|
||||
//! AnimState states: jump, croak, tongue, swim idle.
|
||||
//!
|
||||
//! The brain's idle `RunOne` picks a croak three times as often as a stroll
|
||||
@@ -13,7 +13,10 @@ use super::{
|
||||
},
|
||||
sync,
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::Vec3,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
|
||||
const JUMP: usize = 0;
|
||||
const CROAK: usize = 1;
|
||||
@@ -30,6 +33,25 @@ const EATING_DISTANCE: f64 = 1.75;
|
||||
const DETECTION_DISTANCE: f64 = 10.0;
|
||||
const SCAN_RATE: i32 = 20;
|
||||
|
||||
/// `FrogAi.TIME_BETWEEN_LONG_JUMPS` and the brain memory
|
||||
/// `LONG_JUMP_COOLDOWN_TICKS` (saved with the others, as `finalize` sets it).
|
||||
const TIME_BETWEEN_LONG_JUMPS: (i32, i32) = (100, 140);
|
||||
const LONG_JUMP_COOLDOWN: &str = "long_jump_cooldown";
|
||||
/// `FrogAi`'s `LongJumpToPreferredBlock`: `MAX_LONG_JUMP_WIDTH`,
|
||||
/// `MAX_LONG_JUMP_HEIGHT`, `MAX_JUMP_VELOCITY_MULTIPLIER` and the chance to
|
||||
/// look for a lily pad or big dripleaf first.
|
||||
const JUMP_WIDTH: i32 = 4;
|
||||
const JUMP_HEIGHT: i32 = 2;
|
||||
const JUMP_VELOCITY_MULTIPLIER: f32 = 3.571_428_8;
|
||||
const PREFERRED_BLOCKS_CHANCE: f32 = 0.5;
|
||||
/// `LongJumpToRandomPos.ALLOWED_ANGLES`, `FIND_JUMP_TRIES` and
|
||||
/// `PREPARE_JUMP_DURATION`.
|
||||
const ALLOWED_ANGLES: [i32; 4] = [65, 70, 75, 80];
|
||||
const FIND_JUMP_TRIES: i32 = 20;
|
||||
const PREPARE_JUMP_DURATION: i32 = 40;
|
||||
/// `LongJumpMidJump`'s duration (`TIME_OUT_DURATION`).
|
||||
const MID_JUMP_DURATION: i32 = 100;
|
||||
|
||||
/// `ShootTongue.State`.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Phase {
|
||||
@@ -56,6 +78,8 @@ pub struct State {
|
||||
eat_timer: i32,
|
||||
path_counter: i32,
|
||||
time: i32,
|
||||
/// `LivingEntity.discardFriction`, set through the long jump.
|
||||
pub discard_friction: bool,
|
||||
}
|
||||
|
||||
pub fn register(m: &mut Mob) {
|
||||
@@ -74,6 +98,7 @@ pub fn register(m: &mut Mob) {
|
||||
g.add(0, Float);
|
||||
g.add(1, Panic::new(1.25));
|
||||
g.add(2, ShootTongue);
|
||||
g.add(3, LongJump::default());
|
||||
g.add(4, Croak);
|
||||
g.add(5, Stroll::avoiding_water(1.0).interval(1));
|
||||
g.add(6, LookAtPlayer::new(6.0));
|
||||
@@ -119,6 +144,13 @@ fn can_eat(species: Species, size: i32) -> bool {
|
||||
|
||||
/// `FrogAttackablesSensor` (every 20 ticks) and the memory timers.
|
||||
pub fn server_ai_step(m: &mut Mob, w: &MobWorld) {
|
||||
// `CountDownCooldownTicks(LONG_JUMP_COOLDOWN_TICKS)`: one less each tick,
|
||||
// erased the tick after it reaches 0.
|
||||
match jump_cooldown(m) {
|
||||
Some(t) if t > 0 => set_jump_cooldown(m, Some(t - 1)),
|
||||
Some(_) => set_jump_cooldown(m, None),
|
||||
None => {}
|
||||
}
|
||||
let f = &mut m.extra.frog;
|
||||
f.unreachable.retain_mut(|(_, ttl)| {
|
||||
*ttl -= 1;
|
||||
@@ -310,3 +342,366 @@ impl Goal for ShootTongue {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `LONG_JUMP_COOLDOWN_TICKS`.
|
||||
fn jump_cooldown(m: &Mob) -> Option<i64> {
|
||||
m.extra
|
||||
.memories
|
||||
.iter()
|
||||
.find(|x| x.0 == LONG_JUMP_COOLDOWN)
|
||||
.map(|x| x.1)
|
||||
}
|
||||
fn set_jump_cooldown(m: &mut Mob, ticks: Option<i64>) {
|
||||
let memories = &mut m.extra.memories;
|
||||
let at = memories.iter().position(|x| x.0 == LONG_JUMP_COOLDOWN);
|
||||
match (at, ticks) {
|
||||
(Some(i), Some(t)) => memories[i].1 = t,
|
||||
(Some(i), None) => {
|
||||
memories.remove(i);
|
||||
}
|
||||
(None, Some(t)) => memories.push((LONG_JUMP_COOLDOWN, t, i64::MAX)),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
/// `TIME_BETWEEN_LONG_JUMPS.sample` from the level's random, divided as the
|
||||
/// caller asks.
|
||||
fn sample_cooldown(m: &mut Mob, lr: &mut LegacyRandom, div: i32) {
|
||||
let (lo, hi) = TIME_BETWEEN_LONG_JUMPS;
|
||||
let t = lr.next_int_between_inclusive(lo, hi).unwrap_or(lo) / div;
|
||||
set_jump_cooldown(m, Some(i64::from(t)));
|
||||
}
|
||||
|
||||
/// `FrogAi.isAcceptableLandingSpot`: no fluid in the target, below or above
|
||||
/// it; a lily pad or big dripleaf (`frog_prefer_jump_to`) or a trapdoor; else
|
||||
/// `LongJumpToRandomPos.defaultAcceptableLandingSpot`: a solid block below
|
||||
/// and a path type the frog walks without a malus.
|
||||
fn acceptable_landing(m: &Mob, w: &MobWorld, [x, y, z]: [i32; 3]) -> bool {
|
||||
use super::path::PathType;
|
||||
if (y - 1..=y + 1).any(|y| w.fluid(x, y, z) != crate::blocks::Fluid::None) {
|
||||
return false;
|
||||
}
|
||||
let preferred = |y| w.has_tag("minecraft:frog_prefer_jump_to", w.block_name(x, y, z));
|
||||
if preferred(y) || preferred(y - 1) {
|
||||
return true;
|
||||
}
|
||||
let here = w.path_type_static(x, y, z);
|
||||
if here == PathType::Trapdoor
|
||||
|| w.facts.is_air(w.world.block(x, y, z))
|
||||
&& w.path_type_static(x, y - 1, z) == PathType::Trapdoor
|
||||
{
|
||||
return true;
|
||||
}
|
||||
w.facts.get(w.world.block(x, y - 1, z)).solid_render && m.malus(here) == 0.0
|
||||
}
|
||||
|
||||
/// `LongJumpToRandomPos.PossibleJump`: a block and its weight.
|
||||
type Candidate = ([i32; 3], i32);
|
||||
|
||||
/// `FrogAi`'s long jump activity: `LongJumpToPreferredBlock` looks for a spot
|
||||
/// it cannot walk to, faces it for 40 ticks and leaps; `LongJumpMidJump`
|
||||
/// holds the pose until it lands. One goal here, like the brain activity
|
||||
/// that keeps the idle croak and stroll from starting meanwhile.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct LongJump {
|
||||
candidates: Vec<Candidate>,
|
||||
not_preferred: Vec<Candidate>,
|
||||
wanting_preferred: bool,
|
||||
initial: Option<Vec3>,
|
||||
chosen: Option<Vec3>,
|
||||
/// The chosen spot, looked at while preparing.
|
||||
look: [f64; 3],
|
||||
tries: i32,
|
||||
/// Ticks since the jump was chosen.
|
||||
prepare: i32,
|
||||
/// `LONG_JUMP_MID_JUMP`, and the ticks `LongJumpMidJump` has run.
|
||||
mid_jump: bool,
|
||||
in_air: Option<i32>,
|
||||
}
|
||||
impl LongJump {
|
||||
/// `WeightedRandom.getRandomItem` with the level's random over the
|
||||
/// candidates, removed once drawn.
|
||||
fn draw(&mut self, lr: &mut LegacyRandom) -> Option<Candidate> {
|
||||
let total: i32 = self.candidates.iter().map(|c| c.1).sum();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut pick = lr.next_int_bound(total).unwrap_or(0);
|
||||
let i = self.candidates.iter().position(|c| {
|
||||
pick -= c.1;
|
||||
pick < 0
|
||||
})?;
|
||||
Some(self.candidates.remove(i))
|
||||
}
|
||||
/// `LongJumpToPreferredBlock.getJumpCandidate`: while it wants them, the
|
||||
/// first drawn spot over a preferred block, else the first one passed over.
|
||||
fn candidate(&mut self, w: &MobWorld, lr: &mut LegacyRandom) -> Option<Candidate> {
|
||||
if !self.wanting_preferred {
|
||||
return self.draw(lr);
|
||||
}
|
||||
while !self.candidates.is_empty() {
|
||||
if let Some(c) = self.draw(lr) {
|
||||
let [x, y, z] = c.0;
|
||||
if w.has_tag("minecraft:frog_prefer_jump_to", w.block_name(x, y - 1, z)) {
|
||||
return Some(c);
|
||||
}
|
||||
self.not_preferred.push(c);
|
||||
}
|
||||
}
|
||||
(!self.not_preferred.is_empty()).then(|| self.not_preferred.remove(0))
|
||||
}
|
||||
/// `LongJumpToRandomPos.pickCandidate`: the first acceptable spot it can
|
||||
/// reach in an arc but not by an 8 block path.
|
||||
fn pick(&mut self, m: &mut Mob, w: &MobWorld, lr: &mut LegacyRandom) {
|
||||
while !self.candidates.is_empty() {
|
||||
let Some((target, _)) = self.candidate(w, lr) else {
|
||||
continue;
|
||||
};
|
||||
let here = [m.position.x.floor() as i32, m.position.z.floor() as i32];
|
||||
if here == [target[0], target[2]] || !acceptable_landing(m, w, target) {
|
||||
continue;
|
||||
}
|
||||
let centre = Vec3::new(
|
||||
f64::from(target[0]) + 0.5,
|
||||
f64::from(target[1]) + 0.5,
|
||||
f64::from(target[2]) + 0.5,
|
||||
);
|
||||
let Some(v) = optimal_jump_vector(m, w, centre) else {
|
||||
continue;
|
||||
};
|
||||
self.look = [centre.x, centre.y, centre.z];
|
||||
let walkable = m
|
||||
.create_path_raw(w, &[target], 0, 8.0)
|
||||
.is_some_and(|p| p.reached);
|
||||
if !walkable {
|
||||
self.chosen = Some(v);
|
||||
self.prepare = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LongJumpMidJump.stop`, or the end of a search.
|
||||
fn end(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
if self.mid_jump {
|
||||
if m.on_ground {
|
||||
m.velocity = m.velocity.multiply(Vec3::new(0.1, 1.0, 0.1));
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
cx.events
|
||||
.push(MobEvent::Sound("entity.frog.step", at, 2.0, 1.0));
|
||||
}
|
||||
set_pose(m, sync::STANDING);
|
||||
sample_cooldown(m, cx.lr, 1);
|
||||
}
|
||||
m.extra.frog.discard_friction = false;
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
/// `LongJumpToRandomPos.start`'s candidates: every block of the box but the
|
||||
/// frog's own, weighted by the squared distance, in `betweenClosed` order.
|
||||
fn candidates([x, y, z]: [i32; 3]) -> Vec<Candidate> {
|
||||
let mut out = Vec::new();
|
||||
for dz in -JUMP_WIDTH..=JUMP_WIDTH {
|
||||
for dy in -JUMP_HEIGHT..=JUMP_HEIGHT {
|
||||
for dx in -JUMP_WIDTH..=JUMP_WIDTH {
|
||||
if (dx, dy, dz) != (0, 0, 0) {
|
||||
out.push(([x + dx, y + dy, z + dz], dx * dx + dy * dy + dz * dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// `LongJumpToRandomPos.calculateOptimalJumpVector`: the angles shuffled, the
|
||||
/// first arc that reaches the target clear of blocks. `Collections.shuffle`
|
||||
/// without a random draws from an unseeded one, not the mob's or the level's.
|
||||
fn optimal_jump_vector(m: &Mob, w: &MobWorld, target: Vec3) -> Option<Vec3> {
|
||||
let mut random = crate::sound_engine::SoundEngine::unseeded();
|
||||
let mut angles = ALLOWED_ANGLES;
|
||||
for i in (2..=angles.len()).rev() {
|
||||
let j = random.next_int_bound(i as i32).unwrap_or(0) as usize;
|
||||
angles.swap(i - 1, j);
|
||||
}
|
||||
let max = (m.attrs.jump_strength * f64::from(JUMP_VELOCITY_MULTIPLIER)) as f32;
|
||||
angles
|
||||
.iter()
|
||||
.find_map(|&a| super::breeze::jump_vector(m, target, max, a, Some(w)))
|
||||
}
|
||||
impl Goal for LongJump {
|
||||
goal_clone!();
|
||||
fn flags(&self) -> u8 {
|
||||
MOVE | LOOK
|
||||
}
|
||||
fn interruptable(&self) -> bool {
|
||||
!self.mid_jump
|
||||
}
|
||||
fn every_tick(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
// The activity: no cooldown and out of water. FrogAi.initJumpActivity
|
||||
// also wants TEMPTING_PLAYER and BREED_TARGET absent, and the
|
||||
// LAY_SPAWN activity (IS_PREGNANT) goes before LONG_JUMP.
|
||||
// TODO: gate on them once frogs follow frog_food, breed and lay
|
||||
// frogspawn here; none of the three exists for frogs yet.
|
||||
if jump_cooldown(m).is_some() || m.fluids.in_water {
|
||||
return false;
|
||||
}
|
||||
// checkExtraStartConditions: on the ground, not in lava or on honey.
|
||||
let (x, y, z) = (
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
let can =
|
||||
m.on_ground && !m.fluids.in_lava && cx.w.block_name(x, y, z) != "minecraft:honey_block";
|
||||
if !can {
|
||||
sample_cooldown(m, cx.lr, 2);
|
||||
}
|
||||
can
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, _cx: &mut Cx) {
|
||||
// LongJumpToRandomPos.start.
|
||||
let (x, y, z) = (
|
||||
m.position.x.floor() as i32,
|
||||
m.position.y.floor() as i32,
|
||||
m.position.z.floor() as i32,
|
||||
);
|
||||
*self = Self {
|
||||
candidates: candidates([x, y, z]),
|
||||
tries: FIND_JUMP_TRIES,
|
||||
initial: Some(m.position),
|
||||
..Self::default()
|
||||
};
|
||||
// LongJumpToPreferredBlock.start.
|
||||
self.wanting_preferred = m.random.next_float() < PREFERRED_BLOCKS_CHANCE;
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
if self.mid_jump {
|
||||
// LongJumpMidJump starts the tick after the leap and runs until
|
||||
// the frog is on the ground again, for 100 ticks at most; water
|
||||
// switches the brain to the swim activity, which stops it too.
|
||||
match self.in_air {
|
||||
None => {
|
||||
self.in_air = Some(0);
|
||||
m.extra.frog.discard_friction = true;
|
||||
set_pose(m, sync::LONG_JUMPING);
|
||||
}
|
||||
Some(t) => self.in_air = Some(t + 1),
|
||||
}
|
||||
return !m.on_ground
|
||||
&& !m.fluids.in_water
|
||||
&& self.in_air.is_some_and(|t| t <= MID_JUMP_DURATION);
|
||||
}
|
||||
let valid = self.initial == Some(m.position)
|
||||
&& self.tries > 0
|
||||
&& !m.fluids.in_water
|
||||
&& !(self.chosen.is_none() && self.candidates.is_empty());
|
||||
if !valid {
|
||||
sample_cooldown(m, cx.lr, 2);
|
||||
}
|
||||
valid
|
||||
}
|
||||
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.end(m, cx);
|
||||
}
|
||||
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
if self.mid_jump {
|
||||
return;
|
||||
}
|
||||
let Some(jump) = self.chosen else {
|
||||
self.tries -= 1;
|
||||
self.pick(m, cx.w, cx.lr);
|
||||
return;
|
||||
};
|
||||
m.look_at(self.look);
|
||||
self.prepare += 1;
|
||||
if self.prepare < PREPARE_JUMP_DURATION {
|
||||
return;
|
||||
}
|
||||
// The leap: the body's facing, no friction, the arc's velocity
|
||||
// lengthened by jump boost.
|
||||
m.yaw = m.body_yaw;
|
||||
m.extra.frog.discard_friction = true;
|
||||
let len = jump.length();
|
||||
let boost = m
|
||||
.extra
|
||||
.effects
|
||||
.iter()
|
||||
.find(|e| e.0 == "minecraft:jump_boost")
|
||||
.map_or(0.0, |e| f64::from(0.1_f32 * (e.2 + 1) as f32));
|
||||
m.velocity = jump.scale((len + boost) / len);
|
||||
self.mid_jump = true;
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
cx.events
|
||||
.push(MobEvent::Sound("entity.frog.long_jump", at, 1.0, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
fn frog() -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = std::sync::Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:frog".into();
|
||||
k.species = Species::Frog;
|
||||
Mob::new(1, kind, Vec3::new(0.5, 0.0, 0.5), 0.0)
|
||||
}
|
||||
|
||||
/// `LongJumpToRandomPos.start`: the 9 x 5 x 9 box without the frog's own
|
||||
/// block, x fastest, then y, then z, each weighted by its squared distance.
|
||||
#[test]
|
||||
fn the_candidates_cover_the_jump_box_in_vanilla_order() {
|
||||
let c = candidates([10, 64, -3]);
|
||||
assert_eq!(c.len(), 9 * 5 * 9 - 1);
|
||||
assert_eq!(c[0], ([6, 62, -7], 16 + 4 + 16));
|
||||
assert_eq!(c[1], ([7, 62, -7], 9 + 4 + 16));
|
||||
assert_eq!(c[9], ([6, 63, -7], 16 + 1 + 16));
|
||||
assert_eq!(c[45], ([6, 62, -6], 16 + 4 + 9));
|
||||
assert!(!c.iter().any(|(p, _)| *p == [10, 64, -3]));
|
||||
assert_eq!(c.last().unwrap().0, [14, 66, 1]);
|
||||
}
|
||||
|
||||
/// `WeightedRandom.getRandomItem`: `nextInt(total)` walked down the
|
||||
/// list, and the drawn candidate is removed.
|
||||
#[test]
|
||||
fn a_draw_takes_the_weighted_candidate_out() {
|
||||
let mut j = LongJump {
|
||||
candidates: vec![([0, 0, 0], 1), ([1, 0, 0], 4), ([2, 0, 0], 9)],
|
||||
..LongJump::default()
|
||||
};
|
||||
let mut r = LegacyRandom::new(7);
|
||||
let mut lr = r.clone();
|
||||
let pick = r.next_int_bound(14).unwrap();
|
||||
let want = if pick < 1 {
|
||||
0
|
||||
} else if pick < 5 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
assert_eq!(j.draw(&mut lr).unwrap().0, [want, 0, 0]);
|
||||
assert_eq!(j.candidates.len(), 2);
|
||||
assert!(!j.candidates.iter().any(|c| c.0 == [want, 0, 0]));
|
||||
}
|
||||
|
||||
/// `CountDownCooldownTicks`: the memory falls by one a tick and is gone
|
||||
/// the tick after it reached 0; the sample is 100..=140, halved after a
|
||||
/// failed search, drawn from the level's random and not the frog's.
|
||||
#[test]
|
||||
fn the_long_jump_cooldown_counts_down_and_is_erased() {
|
||||
let mut m = frog();
|
||||
let own = m.random.state();
|
||||
sample_cooldown(&mut m, &mut LegacyRandom::new(3), 2);
|
||||
let t = jump_cooldown(&m).unwrap();
|
||||
assert!((50..=70).contains(&t));
|
||||
assert_eq!(m.random.state(), own);
|
||||
set_jump_cooldown(&mut m, Some(1));
|
||||
set_jump_cooldown(&mut m, Some(0));
|
||||
assert_eq!(jump_cooldown(&m), Some(0));
|
||||
set_jump_cooldown(&mut m, None);
|
||||
assert_eq!(jump_cooldown(&m), None);
|
||||
assert!(!m.extra.memories.iter().any(|x| x.0 == LONG_JUMP_COOLDOWN));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use super::{Mob, MobEvent, MobWorld, Seen, Species, Who, world::TargetInfo};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
jmath,
|
||||
random::RandomSource,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -16,6 +16,9 @@ pub const TARGET: u8 = 8;
|
||||
/// What goals may read and emit during a tick.
|
||||
pub struct Cx<'a, 'w> {
|
||||
pub w: &'a MobWorld<'w>,
|
||||
/// The level's random (`level.getRandom()`), for the draws vanilla's
|
||||
/// behaviours make from it rather than from the mob's own.
|
||||
pub lr: &'a mut LegacyRandom,
|
||||
pub events: &'a mut Vec<MobEvent>,
|
||||
}
|
||||
pub trait Goal: std::fmt::Debug {
|
||||
|
||||
@@ -1045,8 +1045,8 @@ impl Mob {
|
||||
} else {
|
||||
y -= self.gravity();
|
||||
}
|
||||
// LivingEntity.shouldDiscardFriction (a breeze's long jump).
|
||||
if super::breeze::discards_friction(self) {
|
||||
// LivingEntity.shouldDiscardFriction (a breeze's or a frog's long jump).
|
||||
if super::breeze::discards_friction(self) || self.extra.frog.discard_friction {
|
||||
self.velocity = Vec3::new(movement.x, y, movement.z);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -783,6 +783,7 @@ impl B {
|
||||
m.swing();
|
||||
let mut gcx = Cx {
|
||||
w,
|
||||
lr: cx.lr,
|
||||
events: cx.events,
|
||||
};
|
||||
super::ai::do_hurt_target(m, target, &mut gcx);
|
||||
|
||||
@@ -232,7 +232,7 @@ impl Mob {
|
||||
}
|
||||
q
|
||||
}
|
||||
fn create_path_raw(
|
||||
pub(super) fn create_path_raw(
|
||||
&mut self,
|
||||
w: &MobWorld,
|
||||
targets: &[[i32; 3]],
|
||||
|
||||
@@ -1061,8 +1061,14 @@ fn red_overlay(m: &Mob) -> bool {
|
||||
}
|
||||
fn species_scale(m: &Mob, alpha: f32) -> glam::Mat4 {
|
||||
if avatar(m) {
|
||||
// AvatarRenderer.scale, shared by the inventory and world renderer.
|
||||
return glam::Mat4::from_scale(glam::Vec3::splat(0.9375));
|
||||
// AvatarRenderer.scale, shared by the inventory and world renderer, on
|
||||
// top of the entity's own `scale` (`LivingEntityRenderer.submit`).
|
||||
let own = if m.extra.model_scale > 0.0 {
|
||||
m.extra.model_scale
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
return glam::Mat4::from_scale(glam::Vec3::splat(0.9375 * own));
|
||||
}
|
||||
match m.species() {
|
||||
Species::Wither => {
|
||||
@@ -1227,7 +1233,7 @@ pub fn render_with(
|
||||
let shifted =
|
||||
camera - glam::DVec3::new(off[0] + w[0], off[1] + w[1], off[2] + w[2]);
|
||||
let base = base_transform(m, alpha, shifted);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, out);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, true, out);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -1240,6 +1246,7 @@ pub fn render_with(
|
||||
light,
|
||||
data,
|
||||
extra,
|
||||
true,
|
||||
out,
|
||||
);
|
||||
}
|
||||
@@ -1285,6 +1292,24 @@ pub fn cape(
|
||||
// RenderTypes.entitySolid with OverlayTexture.NO_OVERLAY.
|
||||
draw(model, base, &poses, uv, 0xFFFF_FFFF, light, 0, out);
|
||||
}
|
||||
/// `render_with` without the body, for an avatar the viewer can't see:
|
||||
/// `LivingEntityRenderer.submit` gets no render type for an invisible body
|
||||
/// (`isBodyVisible`, `isInvisibleTo`), but its layers still draw
|
||||
/// (`HumanoidArmorLayer`: the armour in `extra`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_layers(
|
||||
m: &Mob,
|
||||
alpha: f32,
|
||||
camera: glam::DVec3,
|
||||
atlas: Atlas,
|
||||
light: u32,
|
||||
data: &MobData,
|
||||
extra: &[(&Part, [f32; 4], u32)],
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let base = base_transform(m, alpha, camera);
|
||||
render_visible(m, alpha, base, atlas, light, data, extra, false, out);
|
||||
}
|
||||
/// A mob drawn under `origin` instead of at its position (the spinning mob
|
||||
/// of `SpawnerRenderer.submitEntityInSpawner`).
|
||||
pub fn render_posed(
|
||||
@@ -1297,7 +1322,7 @@ pub fn render_posed(
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let base = base_transform_at(m, alpha, origin);
|
||||
render_visible(m, alpha, base, atlas, light, data, &[], out);
|
||||
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_visible(
|
||||
@@ -1308,6 +1333,7 @@ fn render_visible(
|
||||
light: u32,
|
||||
data: &MobData,
|
||||
extra: &[(&Part, [f32; 4], u32)],
|
||||
body_visible: bool,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let overlay = u32::from(red_overlay(m));
|
||||
@@ -1321,7 +1347,7 @@ fn render_visible(
|
||||
let mut poses = Poses::new(&model);
|
||||
anim::setup(m, &s, &mut poses, &data.animations);
|
||||
let draw_body = |out: &mut Vec<Vertex>| {
|
||||
if let Some(uv) = atlas(&texture) {
|
||||
if body_visible && let Some(uv) = atlas(&texture) {
|
||||
draw(
|
||||
&model,
|
||||
base,
|
||||
|
||||
@@ -766,4 +766,11 @@ mod tests {
|
||||
assert_eq!(light_limit(Some(&tag("[I;9,2]"))), None);
|
||||
assert_eq!(encode_range((3, 3)), Tag::Int(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawner_defaults_empty() {
|
||||
let s = Spawner::default();
|
||||
assert_eq!(s.next_data(), None);
|
||||
assert!(s.potentials.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -454,7 +454,13 @@ pub fn do_push(m: &mut Mob, w: &MobWorld, who: Who, events: &mut Vec<MobEvent>)
|
||||
}
|
||||
/// `Warden.doHurtTarget`: the attack keyframes (entity event 4), the impact
|
||||
/// sound, a sonic boom cooldown, then `Mob.doHurtTarget`.
|
||||
pub(super) fn do_hurt_target(m: &mut Mob, target: Who, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
pub(super) fn do_hurt_target(
|
||||
m: &mut Mob,
|
||||
target: Who,
|
||||
w: &MobWorld,
|
||||
lr: &mut LegacyRandom,
|
||||
events: &mut Vec<MobEvent>,
|
||||
) {
|
||||
m.anim.states[ROAR].stop();
|
||||
m.anim.states[ATTACK].start(m.age as f32);
|
||||
events.push(MobEvent::EntityEvent {
|
||||
@@ -465,7 +471,7 @@ pub(super) fn do_hurt_target(m: &mut Mob, target: Who, w: &MobWorld, events: &mu
|
||||
let pitch = voice_pitch(m);
|
||||
sound(m, "entity.warden.attack_impact", 10.0, pitch, events);
|
||||
warden_brain::mem(m).sonic_boom_cooldown.set_ttl((), 40);
|
||||
let mut cx = warden_brain::cx(w, events);
|
||||
let mut cx = warden_brain::cx(w, lr, events);
|
||||
super::ai::do_hurt_target(m, target, &mut cx);
|
||||
}
|
||||
|
||||
|
||||
@@ -962,7 +962,7 @@ impl B {
|
||||
}
|
||||
mem(m).look_target.set(Tracker::Entity(target, true));
|
||||
m.swing();
|
||||
warden::do_hurt_target(m, target, w, cx.events);
|
||||
warden::do_hurt_target(m, target, w, cx.lr, cx.events);
|
||||
mem(m)
|
||||
.attack_cooling_down
|
||||
.set_ttl(true, MELEE_ATTACK_COOLDOWN);
|
||||
@@ -1188,8 +1188,12 @@ fn swim_condition(m: &Mob) -> bool {
|
||||
m.fluids.water > m.fluid_jump_threshold() || m.fluids.in_lava
|
||||
}
|
||||
/// The `Cx` the mob library's attacks take.
|
||||
pub(super) fn cx<'a, 'w>(w: &'a MobWorld<'w>, events: &'a mut Vec<MobEvent>) -> Cx<'a, 'w> {
|
||||
Cx { w, events }
|
||||
pub(super) fn cx<'a, 'w>(
|
||||
w: &'a MobWorld<'w>,
|
||||
lr: &'a mut LegacyRandom,
|
||||
events: &'a mut Vec<MobEvent>,
|
||||
) -> Cx<'a, 'w> {
|
||||
Cx { w, lr, events }
|
||||
}
|
||||
|
||||
// ---- Printing ---------------------------------------------------------------
|
||||
|
||||
@@ -19,7 +19,7 @@ use horizon_mods::content::{Block, Content, Rarity};
|
||||
use horizon_world::nbt::{self, JavaString, Limits, Tag};
|
||||
use serde::{Deserialize, Serialize, de};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fmt, fs,
|
||||
ops::Range,
|
||||
path::{Path, PathBuf},
|
||||
@@ -129,6 +129,11 @@ impl J {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn remove(&mut self, key: &str) {
|
||||
if let J::Obj(o) = self {
|
||||
o.retain(|(k, _)| k != key);
|
||||
}
|
||||
}
|
||||
fn get_mut(&mut self, key: &str) -> Option<&mut J> {
|
||||
match self {
|
||||
J::Obj(o) => o.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
@@ -235,6 +240,7 @@ struct NewItem {
|
||||
rename: Rename,
|
||||
max_stack_size: Option<u8>,
|
||||
rarity: Option<Rarity>,
|
||||
components: Vec<(String, J)>,
|
||||
}
|
||||
|
||||
struct Plan<'a> {
|
||||
@@ -248,6 +254,26 @@ struct Plan<'a> {
|
||||
sounds: BTreeMap<String, J>,
|
||||
}
|
||||
|
||||
/// An item's component patch on its entry; `known` holds every type a vanilla item has, so typos fail.
|
||||
fn patch_components(
|
||||
entry: &mut J,
|
||||
patch: &[(String, J)],
|
||||
known: &BTreeSet<String>,
|
||||
) -> std::result::Result<(), String> {
|
||||
for (key, value) in patch {
|
||||
let kind = key.strip_prefix('!').unwrap_or(key);
|
||||
if !known.contains(kind) {
|
||||
return Err(format!("{kind} is no component a vanilla item has"));
|
||||
}
|
||||
if kind == key {
|
||||
entry.set(key, value.clone());
|
||||
} else {
|
||||
entry.remove(kind);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_json(path: &Path) -> Result<J> {
|
||||
Ok(serde_json::from_slice(&fs::read(path)?).map_err(|e| format!("{}: {e}", path.display()))?)
|
||||
}
|
||||
@@ -356,6 +382,7 @@ impl<'a> Plan<'a> {
|
||||
rename: Rename::new(item, &b.decl.id),
|
||||
max_stack_size: None,
|
||||
rarity: None,
|
||||
components: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -365,9 +392,18 @@ impl<'a> Plan<'a> {
|
||||
rename: Rename::new(&i.template, &i.id),
|
||||
max_stack_size: i.max_stack_size,
|
||||
rarity: i.rarity,
|
||||
components: i
|
||||
.components
|
||||
.iter()
|
||||
.map(|(k, v)| Ok((k.clone(), serde_json::from_value(v.clone())?)))
|
||||
.collect::<std::result::Result<_, serde_json::Error>>()
|
||||
.map_err(|e| format!("{}: {e}", i.id))?,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Block items and standalone items share the same registry. Sort
|
||||
// them together by mod ID and entry name before assigning IDs.
|
||||
items.sort_by(|a, b| a.rename.to.cmp(&b.rename.to));
|
||||
Ok(Plan {
|
||||
blocks,
|
||||
items,
|
||||
@@ -458,7 +494,18 @@ impl<'a> Plan<'a> {
|
||||
|
||||
/// Adds each new item's copy of its template's entry to a map by name;
|
||||
/// `components` applies the overrides (items.json).
|
||||
fn items(&self, map: &mut J, components: bool) {
|
||||
fn items(&self, map: &mut J, components: bool) -> Result<()> {
|
||||
let known: BTreeSet<String> = match map {
|
||||
J::Obj(entries) if components => entries
|
||||
.iter()
|
||||
.filter_map(|(_, v)| match v {
|
||||
J::Obj(c) => Some(c.iter().map(|(k, _)| k.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect(),
|
||||
_ => BTreeSet::new(),
|
||||
};
|
||||
for i in &self.items {
|
||||
let Some(v) = map.get(&i.rename.from) else {
|
||||
continue;
|
||||
@@ -478,9 +525,12 @@ impl<'a> Plan<'a> {
|
||||
};
|
||||
v.set("minecraft:rarity", J::Str(r.into()));
|
||||
}
|
||||
patch_components(&mut v, &i.components, &known)
|
||||
.map_err(|e| format!("{}: {e}", i.rename.to))?;
|
||||
}
|
||||
map.set(&i.rename.to, v);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One JSON table, rewritten.
|
||||
@@ -521,7 +571,7 @@ impl<'a> Plan<'a> {
|
||||
blocks.push(v);
|
||||
}
|
||||
}
|
||||
self.items(doc.at("items").ok_or("no items")?, false);
|
||||
self.items(doc.at("items").ok_or("no items")?, false)?;
|
||||
for odds in ["fire/burnOdds", "fire/igniteOdds"] {
|
||||
if let Some(map) = doc.at(odds) {
|
||||
self.names(map, false);
|
||||
@@ -537,7 +587,7 @@ impl<'a> Plan<'a> {
|
||||
self.names(map, false);
|
||||
}
|
||||
}
|
||||
"items.json" => self.items(doc, true),
|
||||
"items.json" => self.items(doc, true)?,
|
||||
"registries.json" => {
|
||||
// Network ids: the game and its server map items and blocks through
|
||||
// this report, so new ones get the ids after vanilla's, in plan order.
|
||||
@@ -698,6 +748,29 @@ const WORLDGEN_BUNDLES: [(&str, &str); 6] = [
|
||||
("placed_feature", "worldgen/placed_features.json"),
|
||||
];
|
||||
|
||||
/// The registry of `command-registries.json` that each bundle's ids join,
|
||||
/// for the commands that suggest them (`/locate`, `/place`).
|
||||
const COMMAND_REGISTRIES: [(&str, &str); 6] = [
|
||||
("worldgen/structures.json", "minecraft:worldgen/structure"),
|
||||
(
|
||||
"worldgen/structure_sets.json",
|
||||
"minecraft:worldgen/structure_set",
|
||||
),
|
||||
(
|
||||
"worldgen/template_pools.json",
|
||||
"minecraft:worldgen/template_pool",
|
||||
),
|
||||
(
|
||||
"worldgen/processor_lists.json",
|
||||
"minecraft:worldgen/processor_list",
|
||||
),
|
||||
("worldgen/features.json", "minecraft:worldgen/feature"),
|
||||
(
|
||||
"worldgen/placed_features.json",
|
||||
"minecraft:worldgen/placed_feature",
|
||||
),
|
||||
];
|
||||
|
||||
/// A mod file that adds to a worldgen bundle: (bundle, id).
|
||||
fn worldgen_entry(file: &str) -> Option<(&'static str, String)> {
|
||||
let (ns, rest) = file.strip_prefix("data/")?.split_once("/worldgen/")?;
|
||||
@@ -838,6 +911,9 @@ impl Overlay<'_> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn out_path(overlay: &Overlay<'_>, rel: &str) -> PathBuf {
|
||||
overlay.out.join(rel)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn link(target: &Path, at: &Path) -> std::io::Result<()> {
|
||||
std::os::unix::fs::symlink(target, at)
|
||||
@@ -1208,6 +1284,32 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result<Vec<String>
|
||||
}
|
||||
overlay.write(bundle, &serde_json::to_vec(&doc)?)?;
|
||||
}
|
||||
// The commands suggest ids from `command-registries.json`: add the
|
||||
// structures, pools, sets and features the mods bring.
|
||||
let registries = out_path(&overlay, "command-registries.json");
|
||||
if let Ok(mut doc) = read_json(®istries) {
|
||||
// Registry names hold `/`, so each key is looked up by itself.
|
||||
let mut add = |keys: &[&str], new: &[J]| {
|
||||
let found = keys.iter().try_fold(&mut doc, |j, k| j.get_mut(k));
|
||||
if let Some(J::Arr(ids)) = found {
|
||||
for id in new {
|
||||
if !ids.contains(id) {
|
||||
ids.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for (bundle, registry) in COMMAND_REGISTRIES {
|
||||
let new: Vec<J> = worldgen
|
||||
.get(bundle)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|(id, _)| J::Str(id.clone()))
|
||||
.collect();
|
||||
add(&["by_registry", registry, "ids"], &new);
|
||||
}
|
||||
overlay.write("command-registries.json", &serde_json::to_vec(&doc)?)?;
|
||||
}
|
||||
// A block without its own blockstate looks like its template, so every
|
||||
// state still has a model.
|
||||
for m in mods {
|
||||
|
||||
@@ -33,6 +33,40 @@ fn json_keeps_key_order_and_numbers() {
|
||||
assert_eq!(serde_json::to_string(&doc).unwrap(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_components_patch_the_template_entry() {
|
||||
let mut entry: J =
|
||||
serde_json::from_str(r#"{"minecraft:max_damage": 250, "minecraft:repairable": {}}"#)
|
||||
.unwrap();
|
||||
let known: BTreeSet<String> = [
|
||||
"minecraft:max_damage",
|
||||
"minecraft:repairable",
|
||||
"minecraft:food",
|
||||
]
|
||||
.map(str::to_owned)
|
||||
.into();
|
||||
let patch = |text: &str| -> Vec<(String, J)> {
|
||||
let J::Obj(o) = serde_json::from_str(text).unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
o
|
||||
};
|
||||
patch_components(
|
||||
&mut entry,
|
||||
&patch(r#"{"minecraft:max_damage": 900, "!minecraft:repairable": {}, "minecraft:food": {"nutrition": 4}}"#),
|
||||
&known,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_string(&entry).unwrap(),
|
||||
r#"{"minecraft:max_damage":900,"minecraft:food":{"nutrition":4}}"#
|
||||
);
|
||||
for typo in [r#"{"minecraft:fod": {}}"#, r#"{"!minecraft:fod": {}}"#] {
|
||||
let e = patch_components(&mut entry, &patch(typo), &known).unwrap_err();
|
||||
assert_eq!(e, "minecraft:fod is no component a vanilla item has");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_merge_without_repeats() {
|
||||
let vanilla = r#"{"values": ["minecraft:stone"]}"#;
|
||||
@@ -48,6 +82,139 @@ fn tags_merge_without_repeats() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Registry IDs and state IDs must not depend on package discovery order.
|
||||
#[test]
|
||||
fn allocated_ids_follow_mod_and_entry_order_after_vanilla() {
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-mod-order-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
let base = scratch.join("base");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
fs::write(
|
||||
base.join("facts.json"),
|
||||
r#"{"states": [["minecraft:iron_block"], ["minecraft:iron_block"], ["minecraft:stone"]],
|
||||
"defaults": {"minecraft:iron_block": 0, "minecraft:stone": 2}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
base.join("gameplay-facts.json"),
|
||||
r#"{"states": [{"item":"minecraft:iron_block"}, {"item":"minecraft:iron_block"},
|
||||
{"item":"minecraft:stone"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
base.join("items.json"),
|
||||
r#"{"minecraft:stone": {}, "minecraft:iron_block": {}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
base.join("registries.json"),
|
||||
r#"{"minecraft:item":{"entries":{"minecraft:stone":{"protocol_id":4},
|
||||
"minecraft:iron_block":{"protocol_id":9}}},
|
||||
"minecraft:block":{"entries":{"minecraft:stone":{"protocol_id":7},
|
||||
"minecraft:iron_block":{"protocol_id":11}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(base.join("assets/minecraft/blockstates")).unwrap();
|
||||
fs::write(
|
||||
base.join("assets/minecraft/blockstates/iron_block.json"),
|
||||
"{}",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(base.join("assets/minecraft/blockstates/stone.json"), "{}").unwrap();
|
||||
let ruby = folder_mod(
|
||||
&scratch.join("ruby"),
|
||||
&[
|
||||
("horizon-mod.toml", &manifest("ruby", "")),
|
||||
(
|
||||
"data/ruby/horizon/block/zblock.json",
|
||||
r#"{"template":"minecraft:stone"}"#,
|
||||
),
|
||||
(
|
||||
"data/ruby/horizon/item/aitem.json",
|
||||
r#"{"template":"minecraft:stone"}"#,
|
||||
),
|
||||
],
|
||||
);
|
||||
let alpha = folder_mod(
|
||||
&scratch.join("alpha"),
|
||||
&[
|
||||
("horizon-mod.toml", &manifest("alpha", "")),
|
||||
(
|
||||
"data/alpha/horizon/block/zblock.json",
|
||||
r#"{"template":"minecraft:iron_block"}"#,
|
||||
),
|
||||
(
|
||||
"data/alpha/horizon/item/aitem.json",
|
||||
r#"{"template":"minecraft:stone"}"#,
|
||||
),
|
||||
],
|
||||
);
|
||||
let mut mods = vec![ruby, alpha];
|
||||
for index in 0..2 {
|
||||
let plan = Plan::new(&base, &mods).unwrap();
|
||||
let blocks: Vec<_> = plan
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| {
|
||||
(
|
||||
block.decl.id.as_str(),
|
||||
block.rename.first,
|
||||
block.rename.states.len(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(blocks, [("alpha:zblock", 3, 2), ("ruby:zblock", 5, 1)]);
|
||||
let items: Vec<_> = plan
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.rename.to.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
items,
|
||||
["alpha:aitem", "alpha:zblock", "ruby:aitem", "ruby:zblock"]
|
||||
);
|
||||
|
||||
// Exercise the same registry rewrite the game and server read.
|
||||
let mut registry: J = serde_json::from_str(
|
||||
r#"{"minecraft:item":{"entries":{"minecraft:stone":{"protocol_id":4},
|
||||
"minecraft:iron_block":{"protocol_id":9}}},
|
||||
"minecraft:block":{"entries":{"minecraft:stone":{"protocol_id":7},
|
||||
"minecraft:iron_block":{"protocol_id":11}}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
plan.json("registries.json", &mut registry).unwrap();
|
||||
let registry: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(®istry).unwrap()).unwrap();
|
||||
let id = |kind: &str, name: &str| {
|
||||
registry[kind]["entries"][name]["protocol_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(id("minecraft:item", "minecraft:stone"), 4);
|
||||
assert_eq!(id("minecraft:item", "minecraft:iron_block"), 9);
|
||||
assert_eq!(id("minecraft:item", "alpha:aitem"), 10);
|
||||
assert_eq!(id("minecraft:item", "alpha:zblock"), 11);
|
||||
assert_eq!(id("minecraft:item", "ruby:aitem"), 12);
|
||||
assert_eq!(id("minecraft:item", "ruby:zblock"), 13);
|
||||
assert_eq!(id("minecraft:block", "minecraft:stone"), 7);
|
||||
assert_eq!(id("minecraft:block", "minecraft:iron_block"), 11);
|
||||
assert_eq!(id("minecraft:block", "alpha:zblock"), 12);
|
||||
assert_eq!(id("minecraft:block", "ruby:zblock"), 13);
|
||||
drop(plan);
|
||||
|
||||
let output = scratch.join(format!("output-{index}"));
|
||||
build(&base, &mods, &output).unwrap();
|
||||
let built: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(output.join("registries.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
built, registry,
|
||||
"the built root must use the tested allocation"
|
||||
);
|
||||
mods.reverse();
|
||||
}
|
||||
fs::remove_dir_all(scratch).unwrap();
|
||||
}
|
||||
|
||||
fn prepared() -> Option<PathBuf> {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
[
|
||||
@@ -61,7 +228,7 @@ fn prepared() -> Option<PathBuf> {
|
||||
/// A content mod folder: a full block, a door and an item-less glass from
|
||||
/// templates, an item, a vanilla tag addition and a language file.
|
||||
fn ruby_mod(dir: &Path) -> ContentMod {
|
||||
let files: [(&str, &str); 8] = [
|
||||
let files: [(&str, &str); 9] = [
|
||||
(
|
||||
"horizon-mod.toml",
|
||||
"id = \"ruby\"\nname = \"Ruby\"\nversion = \"0.1.0\"\napi = \"0.2\"\nside = \"both\"\ncapabilities = [\"registries\"]\n",
|
||||
@@ -83,6 +250,14 @@ fn ruby_mod(dir: &Path) -> ContentMod {
|
||||
"data/ruby/horizon/item/ruby.json",
|
||||
r#"{"template": "minecraft:diamond", "max_stack_size": 16, "rarity": "rare"}"#,
|
||||
),
|
||||
(
|
||||
"data/ruby/horizon/item/ruby_sword.json",
|
||||
r#"{"template": "minecraft:iron_sword", "components": {"minecraft:max_damage": 900,
|
||||
"minecraft:attribute_modifiers": [{"type": "minecraft:attack_damage",
|
||||
"id": "minecraft:base_attack_damage", "amount": 7.0,
|
||||
"operation": "add_value", "slot": "mainhand"}],
|
||||
"!minecraft:repairable": {}}}"#,
|
||||
),
|
||||
// Carried-over tags name blocks left out and other mods' ids too.
|
||||
(
|
||||
"data/minecraft/tags/block/mineable/pickaxe.json",
|
||||
@@ -252,6 +427,12 @@ fn content_root_loads_with_every_table() {
|
||||
assert_eq!(ruby_block.block, Some(ruby));
|
||||
let gem = &items.items["ruby:ruby"];
|
||||
assert_eq!(gem.max_stack, 16);
|
||||
let sword = &items.items["ruby:ruby_sword"];
|
||||
let iron_sword = &items.items["minecraft:iron_sword"];
|
||||
assert_eq!((sword.max_damage, iron_sword.max_damage), (900, 250));
|
||||
assert_eq!(sword.modifiers.len(), 1);
|
||||
assert_eq!(sword.modifiers[0].2, 7.0);
|
||||
assert_eq!(sword.tool.len(), iron_sword.tool.len());
|
||||
assert!(!items.items.contains_key("ruby:ruby_glass"));
|
||||
let pickaxe = &items.block_tags["minecraft:mineable/pickaxe"];
|
||||
assert!(pickaxe.contains("ruby:ruby_block"));
|
||||
@@ -679,6 +860,24 @@ fn content_root_carries_a_structure_mod() {
|
||||
assert!(!ours.contains_key("othermod:houses"));
|
||||
}
|
||||
|
||||
// The commands suggest the mod's structure, set and pool.
|
||||
let registries: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(root.join("command-registries.json")).unwrap()).unwrap();
|
||||
for (registry, id) in [
|
||||
("minecraft:worldgen/structure", "huts:hut"),
|
||||
("minecraft:worldgen/structure_set", "huts:huts"),
|
||||
("minecraft:worldgen/template_pool", "huts:hut/start"),
|
||||
] {
|
||||
let ids = registries["by_registry"][registry]["ids"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert!(ids.iter().any(|v| v == id), "{registry} lacks {id}");
|
||||
assert!(
|
||||
ids.iter()
|
||||
.any(|v| v.as_str().unwrap().starts_with("minecraft:"))
|
||||
);
|
||||
}
|
||||
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let biomes = crate::biome::Biomes::load(&root).unwrap();
|
||||
let terrain = crate::vanillagen::VanillaTerrain::new(
|
||||
|
||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user