mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Compare commits
160
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
baa379d717 | ||
|
|
28a65af5fc | ||
|
|
a6751f2aa9 | ||
|
|
ea60e1e062 | ||
|
|
047815c5d7 | ||
|
|
d22aecd526 | ||
|
|
01b4e5cbc4 | ||
|
|
7ebf7d26cb | ||
|
|
cbea22fbb6 | ||
|
|
319876a186 | ||
|
|
9093960719 | ||
|
|
68e57e86c5 | ||
|
|
ab0309581c | ||
|
|
46ac49ad05 | ||
|
|
d23ab5ae50 | ||
|
|
9ad4c9fb8b | ||
|
|
c7065027b1 | ||
|
|
b97e39e074 | ||
|
|
1deaac7263 | ||
|
|
26f099d168 | ||
|
|
ef66d5b892 | ||
|
|
b732e582b9 | ||
|
|
f145c903b6 | ||
|
|
d009ebd140 | ||
|
|
0c49146fc8 | ||
|
|
1fb8e84561 | ||
|
|
cbbdf773e2 | ||
|
|
e1207bb017 | ||
|
|
94a737918a | ||
|
|
7ed5855e4a | ||
|
|
e3cbe2688f | ||
|
|
7dc72d6c33 | ||
|
|
f4e1a98127 | ||
|
|
e5087bdb5a | ||
|
|
0e7918fb88 | ||
|
|
e5076cd6ab | ||
|
|
3d4cade73b | ||
|
|
1332c90868 | ||
|
|
1b38710e92 | ||
|
|
aaa301b2e5 | ||
|
|
3052227936 | ||
|
|
95a366fe89 | ||
|
|
fc0e275414 | ||
|
|
5dc0515248 | ||
|
|
20a3c3e399 | ||
|
|
0a6020695b | ||
|
|
dfde0ac091 | ||
|
|
dc274d681c | ||
|
|
246a071dbc | ||
|
|
1e965ec5a8 | ||
|
|
a7ff35cf7b | ||
|
|
3e31a55950 | ||
|
|
4e910746ce | ||
|
|
06254caea3 | ||
|
|
7b78be8d4e | ||
|
|
73602536bb | ||
|
|
cb3f4387db | ||
|
|
5815bf4f11 | ||
|
|
9c21a95409 | ||
|
|
74f0265db1 | ||
|
|
fe47804f28 | ||
|
|
f12d87b5d9 | ||
|
|
0b63d040ea | ||
|
|
f9b002f804 | ||
|
|
f065bab2e4 | ||
|
|
cefa6153d1 | ||
|
|
8fdbad2d4b | ||
|
|
38da7d8d6e | ||
|
|
bb6305da25 | ||
|
|
d385d188f3 | ||
|
|
f383fdf74c | ||
|
|
f4f9897c9e | ||
|
|
35eb60e2f1 | ||
|
|
5a1c095724 | ||
|
|
bc78cba5b9 | ||
|
|
62c0834880 | ||
|
|
98c0883c8f | ||
|
|
0c06082dfa | ||
|
|
e2de446128 | ||
|
|
f7efb0d710 | ||
|
|
9da77d9d4b | ||
|
|
a7f1f70508 | ||
|
|
6e8ee3ed07 | ||
|
|
a5a0e1bb98 | ||
|
|
7e459e5fb1 | ||
|
|
e932a2da61 | ||
|
|
2677a397e5 | ||
|
|
e0f11804f2 | ||
|
|
e7e5413667 | ||
|
|
7fb1f77870 | ||
|
|
8a9db2dc7b | ||
|
|
4d13a4ebcc | ||
|
|
a20aaf5396 | ||
|
|
f44042e0eb | ||
|
|
4a2a5b9977 | ||
|
|
1eb34fa68e | ||
|
|
4623d2e59f | ||
|
|
04cd6ece23 | ||
|
|
9218dc3e7d | ||
|
|
5b66bd0ec0 | ||
|
|
3451e4e29a | ||
|
|
a30b9525d1 | ||
|
|
ef2ec4c0dc | ||
|
|
a0964a8010 | ||
|
|
d91bd61281 | ||
|
|
289c2df527 | ||
|
|
6b96bb96b6 | ||
|
|
e7614d2197 | ||
|
|
bc33470d5c | ||
|
|
c48c2f2167 | ||
|
|
cef862471f | ||
|
|
44d9bddebd | ||
|
|
984273f5c8 | ||
|
|
48e731483a | ||
|
|
cd70f518e9 | ||
|
|
f7300bd96b | ||
|
|
8202f02f4e | ||
|
|
b6cd09442e | ||
|
|
e3b1ae512f | ||
|
|
5b4dd8b72e | ||
|
|
5f269772d9 | ||
|
|
7211d029ab | ||
|
|
25e00a0ecf | ||
|
|
1d2d495686 | ||
|
|
3ea82790ea | ||
|
|
31ead52d21 | ||
|
|
7ef092fabc | ||
|
|
326719539d | ||
|
|
dc418c7623 | ||
|
|
d3559e2a5f | ||
|
|
c90893c29c | ||
|
|
dd130f1902 | ||
|
|
1a9040ad79 | ||
|
|
8ebcf6c13d | ||
|
|
08b9765278 | ||
|
|
380d5ebe9f | ||
|
|
18c23a6df3 | ||
|
|
445183a7df | ||
|
|
380d71d8a8 | ||
|
|
85f53198ce | ||
|
|
0543cf0345 | ||
|
|
13e441e235 | ||
|
|
e0a48b3f66 | ||
|
|
d35efa25ec | ||
|
|
b8b15b1682 | ||
|
|
05f8ec650d | ||
|
|
2702542c73 | ||
|
|
3e930fc74b | ||
|
|
04ec03745d | ||
|
|
6a6b5957eb | ||
|
|
8c18d9429f | ||
|
|
2ee238e5f1 | ||
|
|
6bb648db1e | ||
|
|
42388d2512 | ||
|
|
2deaab2602 | ||
|
|
ca28a0edad | ||
|
|
694568a89c | ||
|
|
80df20837b | ||
|
|
2092c5a2e8 | ||
|
|
66969d8a93 |
No files matched your search
@@ -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"
|
||||
|
||||
@@ -76,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
|
||||
```
|
||||
|
||||
@@ -169,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",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
|
||||
//! value, `ComponentSerialization` turns it into styled text.
|
||||
use crate::text::{Style, Text, named_color};
|
||||
use crate::text::{InlineObject, Style, Text, named_color};
|
||||
use horizon_world::nbt::Tag;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -296,6 +296,10 @@ pub fn to_text_with(
|
||||
let style = object_style(o);
|
||||
let content = if let Some(t) = contents(o) {
|
||||
t
|
||||
} else if let Some(object) = inline_object(o, resolve, contents) {
|
||||
let mut text = Text::literal("\u{fffc}");
|
||||
text.spans[0].style.inline_object = Some(object);
|
||||
text
|
||||
} else if let Some(t) = o.get("text") {
|
||||
match t {
|
||||
Value::String(s) => Text::literal(s.clone()),
|
||||
@@ -332,12 +336,74 @@ pub fn to_text_with(
|
||||
{
|
||||
out.push(to_text_with(extra, resolve, contents).with_style(&style));
|
||||
}
|
||||
out
|
||||
if o.contains_key("object") || o.contains_key("player") || o.contains_key("sprite") {
|
||||
out.with_component(Value::Object(o.clone()))
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
Value::Null => Text::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_object(
|
||||
object: &serde_json::Map<String, Value>,
|
||||
resolve: &mut dyn FnMut(&str) -> Text,
|
||||
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
|
||||
) -> Option<InlineObject> {
|
||||
let kind = object.get("object").and_then(Value::as_str);
|
||||
let fallback = object
|
||||
.get("fallback")
|
||||
.map(|value| to_text_with(value, resolve, contents).plain());
|
||||
match kind {
|
||||
Some("player") | None if object.contains_key("player") => {
|
||||
let profile = object.get("player")?.clone();
|
||||
// PlayerSprite.defaultFallback.
|
||||
let fallback = fallback.unwrap_or_else(|| {
|
||||
match profile
|
||||
.as_str()
|
||||
.or_else(|| profile.get("name").and_then(Value::as_str))
|
||||
{
|
||||
Some(name) => format!("[{name} head]"),
|
||||
None => "[unknown player head]".to_owned(),
|
||||
}
|
||||
});
|
||||
Some(InlineObject::Player {
|
||||
profile,
|
||||
hat: object.get("hat").and_then(Value::as_bool).unwrap_or(true),
|
||||
fallback,
|
||||
})
|
||||
}
|
||||
Some("atlas") | None if object.contains_key("sprite") => {
|
||||
let atlas = object
|
||||
.get("atlas")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("minecraft:blocks")
|
||||
.to_owned();
|
||||
let sprite = object.get("sprite")?.as_str()?.to_owned();
|
||||
// AtlasSprite.defaultFallback: short names, the atlas unless blocks.
|
||||
let fallback = fallback.unwrap_or_else(|| {
|
||||
let short = |id: &str| match id.split_once(':') {
|
||||
Some(("minecraft", path)) => path.to_owned(),
|
||||
Some(_) => id.to_owned(),
|
||||
None => id.to_owned(),
|
||||
};
|
||||
if short(&atlas) == "blocks" {
|
||||
format!("[{}]", short(&sprite))
|
||||
} else {
|
||||
format!("[{}@{}]", short(&sprite), short(&atlas))
|
||||
}
|
||||
});
|
||||
Some(InlineObject::Atlas {
|
||||
atlas,
|
||||
sprite,
|
||||
fallback,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
|
||||
/// else is a component (`ComponentSerialization`).
|
||||
pub fn name_text(tag: &Tag) -> Text {
|
||||
@@ -351,6 +417,44 @@ pub fn name_text(tag: &Tag) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn player_object_survives_text_parsing_and_keeps_its_siblings() {
|
||||
let component = serde_json::json!({
|
||||
"text": "<",
|
||||
"extra": [
|
||||
{"object": "player", "player": {"name": "elyapluggin"}, "hat": false,
|
||||
"fallback": "[head]"},
|
||||
{"text": " >", "color": "gold"}
|
||||
]
|
||||
});
|
||||
let text = to_text(&component, &mut |_| Text::empty());
|
||||
assert_eq!(text.plain(), "<[head] >");
|
||||
assert!(matches!(
|
||||
&text.spans[1].style.inline_object,
|
||||
Some(InlineObject::Player { hat: false, .. })
|
||||
));
|
||||
assert!(text.spans[2].style.inline_object.is_none());
|
||||
assert_eq!(text.component(), component);
|
||||
let sprite = serde_json::json!({"object": "atlas", "sprite": "minecraft:item/porkchop"});
|
||||
let text = to_text(&sprite, &mut |_| Text::empty());
|
||||
assert!(matches!(
|
||||
&text.spans[0].style.inline_object,
|
||||
Some(InlineObject::Atlas { sprite, .. }) if sprite == "minecraft:item/porkchop"
|
||||
));
|
||||
// ObjectContents.visit without a fallback: the sprite's defaultFallback.
|
||||
assert_eq!(text.plain(), "[item/porkchop]");
|
||||
let head = serde_json::json!({"object": "player", "player": {"name": "Steve"}});
|
||||
assert_eq!(
|
||||
to_text(&head, &mut |_| Text::empty()).plain(),
|
||||
"[Steve head]"
|
||||
);
|
||||
let gui = serde_json::json!({"atlas": "gui", "sprite": "hud/heart/full"});
|
||||
assert_eq!(
|
||||
to_text(&gui, &mut |_| Text::empty()).plain(),
|
||||
"[hud/heart/full@gui]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snbt_and_json_components() {
|
||||
let input = "{text:'Hi ',color:red,extra:[{text:\"there\",bold:1b}]} rest";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,28 @@ pub enum ClickEvent {
|
||||
OpenFile(String),
|
||||
}
|
||||
|
||||
/// A non-character `minecraft:object` embedded in a text component.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum InlineObject {
|
||||
Player {
|
||||
profile: Value,
|
||||
hat: bool,
|
||||
fallback: String,
|
||||
},
|
||||
Atlas {
|
||||
atlas: String,
|
||||
sprite: String,
|
||||
fallback: String,
|
||||
},
|
||||
}
|
||||
impl InlineObject {
|
||||
pub fn fallback(&self) -> &str {
|
||||
match self {
|
||||
Self::Player { fallback, .. } | Self::Atlas { fallback, .. } => fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text style; `None` fields inherit from the enclosing style.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Style {
|
||||
@@ -77,6 +99,8 @@ pub struct Style {
|
||||
/// The font (`FontDescription.Resource`), a full id like
|
||||
/// `minecraft:alt`; the default font when unset.
|
||||
pub font: Option<String>,
|
||||
/// Content metadata for a single U+FFFC object span; never inherited.
|
||||
pub inline_object: Option<InlineObject>,
|
||||
}
|
||||
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
|
||||
pub fn font_id(v: &Value) -> Option<String> {
|
||||
@@ -337,7 +361,13 @@ impl Text {
|
||||
}
|
||||
/// Component.getString: the plain characters.
|
||||
pub fn plain(&self) -> String {
|
||||
self.spans.iter().map(|s| s.text.as_str()).collect()
|
||||
self.spans
|
||||
.iter()
|
||||
.map(|s| match &s.style.inline_object {
|
||||
Some(object) if s.text == "\u{fffc}" => object.fallback(),
|
||||
_ => s.text.as_str(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.spans.iter().all(|s| s.text.is_empty())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -74,9 +74,28 @@ pub enum BlockEntity {
|
||||
/// `DaylightDetectorBlockEntity`: no data, only the ticker
|
||||
/// (`DaylightDetectorBlock.tickEntity`).
|
||||
DaylightDetector,
|
||||
/// Suspicious sand and gravel: saved loot and the transient brushed face.
|
||||
Brushable(Box<Brushable>),
|
||||
/// A shelf holds three displayed item stacks.
|
||||
Shelf(Box<Shelf>),
|
||||
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
|
||||
ChiseledBookshelf(Box<ChiseledBookshelf>),
|
||||
}
|
||||
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Brushable {
|
||||
pub loot_table: Option<horizon_world::nbt::Tag>,
|
||||
pub loot_seed: Option<horizon_world::nbt::Tag>,
|
||||
pub item: Option<ItemStack>,
|
||||
/// Direction's legacy network id; not saved to disk by vanilla.
|
||||
pub hit_direction: Option<i32>,
|
||||
}
|
||||
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Shelf {
|
||||
pub items: [Option<ItemStack>; 3],
|
||||
pub align_to_bottom: bool,
|
||||
}
|
||||
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ChiseledBookshelf {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
|
||||
//! from the data-driven `context_int_provider`/`context_float_provider` files.
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
|
||||
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
@@ -230,6 +230,14 @@ impl BlockEntity {
|
||||
recipes_used: HashMap::new(),
|
||||
}));
|
||||
}
|
||||
if matches!(
|
||||
name,
|
||||
"minecraft:suspicious_sand"
|
||||
| "minecraft:suspicious_gravel"
|
||||
| "minecraft:brushable_block"
|
||||
) {
|
||||
return Some(Self::Brushable(Box::default()));
|
||||
}
|
||||
if name == "minecraft:creaking_heart" {
|
||||
return Some(Self::CreakingHeart(Box::default()));
|
||||
}
|
||||
@@ -292,6 +300,9 @@ impl BlockEntity {
|
||||
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
|
||||
return Some(Self::Campfire(Box::default()));
|
||||
}
|
||||
if name == "minecraft:shelf" || name.ends_with("_shelf") {
|
||||
return Some(Self::Shelf(Box::default()));
|
||||
}
|
||||
if name == "minecraft:chiseled_bookshelf" {
|
||||
return Some(Self::ChiseledBookshelf(Box::default()));
|
||||
}
|
||||
@@ -334,8 +345,10 @@ impl BlockEntity {
|
||||
Self::Skull(_) => "minecraft:skull",
|
||||
Self::EndGateway(_) => "minecraft:end_gateway",
|
||||
Self::DaylightDetector => "minecraft:daylight_detector",
|
||||
Self::Brushable(_) => "minecraft:brushable_block",
|
||||
Self::Campfire(_) => "minecraft:campfire",
|
||||
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
|
||||
Self::Shelf(_) => "minecraft:shelf",
|
||||
}
|
||||
}
|
||||
pub fn contents(&self) -> Vec<ItemStack> {
|
||||
@@ -354,13 +367,15 @@ impl BlockEntity {
|
||||
| Self::CommandBlock(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => Vec::new(),
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => Vec::new(),
|
||||
Self::Lectern(l) => l.book.iter().cloned().collect(),
|
||||
Self::Sculk(_) => Vec::new(),
|
||||
Self::Banner(_) => Vec::new(),
|
||||
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
|
||||
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
|
||||
}
|
||||
}
|
||||
pub fn slots(&self) -> &[Option<ItemStack>] {
|
||||
@@ -376,9 +391,11 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &[],
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &[],
|
||||
Self::Crafter(c) => &c.items,
|
||||
Self::ChiseledBookshelf(b) => &b.items,
|
||||
Self::Shelf(b) => &b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -402,9 +419,11 @@ impl BlockEntity {
|
||||
| Self::Banner(_)
|
||||
| Self::EndGateway(_)
|
||||
| Self::Skull(_)
|
||||
| Self::DaylightDetector => &mut [],
|
||||
| Self::DaylightDetector
|
||||
| Self::Brushable(_) => &mut [],
|
||||
Self::Crafter(c) => &mut c.items,
|
||||
Self::ChiseledBookshelf(b) => &mut b.items,
|
||||
Self::Shelf(b) => &mut b.items,
|
||||
Self::CreakingHeart(_)
|
||||
| Self::Spawner(_)
|
||||
| Self::TrialSpawner(_)
|
||||
@@ -486,6 +505,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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -896,6 +896,9 @@ impl Font {
|
||||
std::mem::take(&mut self.cache.borrow_mut().region.pending)
|
||||
}
|
||||
pub fn advance(&self, c: char, style: &Style) -> f32 {
|
||||
if c == '\u{fffc}' && style.inline_object.is_some() {
|
||||
return 8.0;
|
||||
}
|
||||
let g = self.glyph_styled(c, style);
|
||||
g.advance + if style.is_bold() { g.offset } else { 0.0 }
|
||||
}
|
||||
|
||||
@@ -357,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,
|
||||
@@ -384,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,
|
||||
@@ -581,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.
|
||||
@@ -1174,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 {}",
|
||||
@@ -2586,6 +2600,8 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
display: DisplayWatch::default(),
|
||||
display_fresh: false,
|
||||
instance,
|
||||
adapter,
|
||||
surface,
|
||||
@@ -3299,6 +3315,7 @@ impl Renderer {
|
||||
paper_white: f32,
|
||||
peak: f32,
|
||||
black: f32,
|
||||
ui: f32,
|
||||
marker: &std::path::Path,
|
||||
) {
|
||||
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
|
||||
@@ -3311,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();
|
||||
@@ -3323,6 +3350,9 @@ 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, hdr.black) != (paper_white, peak, black)
|
||||
{
|
||||
@@ -3332,6 +3362,20 @@ impl Renderer {
|
||||
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();
|
||||
@@ -5219,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(
|
||||
@@ -5227,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,
|
||||
@@ -5330,22 +5385,10 @@ 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) {
|
||||
let size = [self.config.width, self.config.height];
|
||||
Some((
|
||||
path,
|
||||
copy_frame(&self.device, &mut encoder, &target_texture, size),
|
||||
))
|
||||
} 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 {
|
||||
@@ -5376,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()),
|
||||
);
|
||||
}
|
||||
@@ -5464,7 +5507,32 @@ fn copy_frame(
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
|
||||
/// 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,
|
||||
) -> 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);
|
||||
@@ -5499,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,
|
||||
);
|
||||
@@ -5510,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 {
|
||||
@@ -6040,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),
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -75,6 +81,8 @@ pub struct Hdr {
|
||||
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 {
|
||||
@@ -140,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,
|
||||
@@ -149,6 +199,8 @@ impl Hdr {
|
||||
Self {
|
||||
layout,
|
||||
pipeline,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params,
|
||||
output,
|
||||
format,
|
||||
@@ -157,6 +209,7 @@ impl Hdr {
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
ui: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,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;
|
||||
@@ -223,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`.
|
||||
@@ -244,7 +349,7 @@ impl Hdr {
|
||||
f32::from(u8::from(self.debug)),
|
||||
f32::from(u8::from(self.output == Output::Pq)),
|
||||
self.black.clamp(0.0, paper),
|
||||
0.0,
|
||||
self.ui.clamp(0.25, 1.0),
|
||||
0.0,
|
||||
];
|
||||
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms));
|
||||
|
||||
@@ -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,13 +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.
|
||||
@@ -384,9 +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(),
|
||||
@@ -449,9 +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(),
|
||||
@@ -469,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:{}\nhdrBlackLevel:{}\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(),
|
||||
@@ -500,6 +555,7 @@ impl Graphics {
|
||||
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,
|
||||
@@ -508,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 {
|
||||
@@ -618,12 +690,18 @@ mod tests {
|
||||
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,9 +40,12 @@ pub enum H {
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
Hdr,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
HdrBlackLevel,
|
||||
HdrPeak,
|
||||
HdrPaperWhite,
|
||||
HdrUiBrightness,
|
||||
BatteryMode,
|
||||
GraphicsApi,
|
||||
}
|
||||
@@ -121,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,
|
||||
@@ -231,25 +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::HdrBlackLevel,
|
||||
None,
|
||||
"HDR: your display's darkest black. 0 for OLED; LCDs glow a little.",
|
||||
),
|
||||
h(
|
||||
H::HdrPeak,
|
||||
None,
|
||||
"HDR: how bright the brightest highlights get. Your display's peak.",
|
||||
),
|
||||
h(
|
||||
H::HdrPaperWhite,
|
||||
None,
|
||||
"HDR: how bright white paper and menus look. Match your desktop.",
|
||||
),
|
||||
h(
|
||||
H::PresentMode,
|
||||
None,
|
||||
@@ -357,9 +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,
|
||||
}
|
||||
}
|
||||
@@ -410,12 +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(
|
||||
@@ -438,9 +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,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -516,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;
|
||||
@@ -617,6 +685,7 @@ pub enum Action {
|
||||
SliderDrag,
|
||||
OpenAdvanced,
|
||||
OpenSimple,
|
||||
OpenHdr,
|
||||
Done,
|
||||
}
|
||||
|
||||
@@ -700,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,
|
||||
@@ -761,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(
|
||||
@@ -776,6 +854,7 @@ impl GraphicsUi {
|
||||
&label,
|
||||
value,
|
||||
hover || self.dragging == Some(item),
|
||||
live,
|
||||
),
|
||||
None => STYLE.button(
|
||||
batch,
|
||||
@@ -825,6 +904,7 @@ impl GraphicsUi {
|
||||
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 {
|
||||
@@ -880,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);
|
||||
@@ -912,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();
|
||||
|
||||
@@ -18,6 +18,8 @@ struct Params {
|
||||
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;
|
||||
|
||||
@@ -80,15 +82,29 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
|
||||
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));
|
||||
@@ -103,6 +119,8 @@ fn lift_black(e: 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 {
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
|
||||
@@ -59,6 +59,8 @@ pub struct HudAtlas {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub sprites: HashMap<String, Rect>,
|
||||
/// Resolved player-profile names and UUIDs for inline head components.
|
||||
pub object_skins: HashMap<String, String>,
|
||||
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
|
||||
/// which puts their particles on vanilla's translucent layer.
|
||||
pub translucent: HashSet<String>,
|
||||
@@ -252,6 +254,24 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
|
||||
out.into_iter().map(|(_, image)| image).collect()
|
||||
}
|
||||
impl HudAtlas {
|
||||
/// Keep a ready profile skin when another source only has its default.
|
||||
pub fn set_object_skin(&mut self, key: String, skin: String) {
|
||||
use std::collections::hash_map::Entry;
|
||||
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) {
|
||||
self.object_skins.clear();
|
||||
}
|
||||
match self.object_skins.entry(key) {
|
||||
Entry::Occupied(entry)
|
||||
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
|
||||
}
|
||||
Entry::Occupied(mut entry) => {
|
||||
entry.insert(skin);
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(skin);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
|
||||
pub fn glyph(&self, ch: char) -> Option<(Rect, f32)> {
|
||||
let g = self.font.glyph(ch);
|
||||
@@ -592,6 +612,7 @@ impl HudAtlas {
|
||||
width,
|
||||
height,
|
||||
sprites,
|
||||
object_skins: HashMap::new(),
|
||||
translucent,
|
||||
flags,
|
||||
sprite_animations,
|
||||
@@ -641,6 +662,8 @@ pub struct HudBatch {
|
||||
pub glint_alpha: f32,
|
||||
/// Screen's first vertex: the preceding HUD is part of its blurred background.
|
||||
pub blur_at: u32,
|
||||
/// Player object profiles encountered while painting text this frame.
|
||||
pub inline_profiles: Vec<serde_json::Value>,
|
||||
}
|
||||
/// How a run of HUD vertices blends and tests depth.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -692,6 +715,7 @@ impl HudBatch {
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Draw the vertices added since `start` with the inverting blend.
|
||||
@@ -1042,22 +1066,43 @@ impl HudBatch {
|
||||
let mut cursor = x;
|
||||
let mut previous = None;
|
||||
for (i, (ch, style)) in text.chars().enumerate() {
|
||||
let object = (ch == '\u{fffc}')
|
||||
.then_some(style.inline_object.as_ref())
|
||||
.flatten();
|
||||
if let Some(crate::text::InlineObject::Player { profile, .. }) = object
|
||||
&& self.inline_profiles.len() < 256
|
||||
&& !self.inline_profiles.contains(profile)
|
||||
{
|
||||
self.inline_profiles.push(profile.clone());
|
||||
}
|
||||
// Font.PreparedTextBuilder.accept: lines start 1 left of a
|
||||
// sequence's first character. Language.getVisualOrder makes a
|
||||
// sequence of each run of one style, which world text has.
|
||||
let first = i == 0 || world && previous != Some(style);
|
||||
previous = Some(style);
|
||||
// The style's font (`Style.getFont`), the default one unless set.
|
||||
let mut g = font.glyph_styled(ch, style);
|
||||
let mut g = if object.is_some() {
|
||||
crate::font::Glyph {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: 0.0,
|
||||
bottom: 8.0,
|
||||
advance: 8.0,
|
||||
offset: 0.0,
|
||||
}
|
||||
} else {
|
||||
font.glyph_styled(ch, style)
|
||||
};
|
||||
// Font.StringRenderOutput.accept: obfuscated characters draw a
|
||||
// random glyph of the same width each frame.
|
||||
if style.obfuscated == Some(true) && ch != ' ' {
|
||||
if object.is_none() && style.obfuscated == Some(true) && ch != ' ' {
|
||||
g = font.random_glyph(style, g.advance);
|
||||
}
|
||||
let c = style.color.map_or(color, |rgb| {
|
||||
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
|
||||
});
|
||||
let bold = style.is_bold();
|
||||
let bold = object.is_none() && style.is_bold();
|
||||
let advance = g.advance + if bold { g.offset } else { 0.0 };
|
||||
glyphs.push((
|
||||
cursor,
|
||||
@@ -1069,6 +1114,7 @@ impl HudBatch {
|
||||
style.strikethrough == Some(true),
|
||||
first,
|
||||
advance,
|
||||
object.cloned(),
|
||||
));
|
||||
cursor += advance;
|
||||
}
|
||||
@@ -1086,20 +1132,26 @@ impl HudBatch {
|
||||
)
|
||||
};
|
||||
for &is_shadow in passes {
|
||||
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
|
||||
for (gx, g, c, bold, italic, underline, strike, first, advance, object) in &glyphs {
|
||||
if let Some(object) = object {
|
||||
if !is_shadow {
|
||||
self.draw_inline_object(atlas, object, *gx, y, alpha);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// In the world a glyph whose shadow is 0 has none (hasShadow).
|
||||
let has_shadow = shadow && !(world && shade(c) == 0);
|
||||
let has_shadow = shadow && !(world && shade(*c) == 0);
|
||||
if is_shadow && !has_shadow {
|
||||
continue;
|
||||
}
|
||||
let (dx, col) = if is_shadow {
|
||||
(g.offset, shade(c))
|
||||
(g.offset, shade(*c))
|
||||
} else {
|
||||
(0.0, c)
|
||||
(0.0, *c)
|
||||
};
|
||||
let (px, py) = (gx + dx, y + dx);
|
||||
let shear = |v: f32| if italic { 1.0 - 0.25 * v } else { 0.0 };
|
||||
let thick = if bold { 0.1 } else { 0.0 };
|
||||
let (px, py) = (*gx + dx, y + dx);
|
||||
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
|
||||
let thick = if *bold { 0.1 } else { 0.0 };
|
||||
let draw = |batch: &mut Self, ox: f32| match g.rect {
|
||||
Some(r) => {
|
||||
let (x0, x1, y0, y1) = (
|
||||
@@ -1144,24 +1196,86 @@ impl HudBatch {
|
||||
let start = self.vertices.len();
|
||||
draw(self, 0.0);
|
||||
lift(self, start, base);
|
||||
if bold {
|
||||
if *bold {
|
||||
let start = self.vertices.len();
|
||||
draw(self, g.offset);
|
||||
lift(self, start, base + 0.001);
|
||||
}
|
||||
let x0 = if first { px - 1.0 } else { px };
|
||||
let x0 = if *first { px - 1.0 } else { px };
|
||||
let start = self.vertices.len();
|
||||
if strike {
|
||||
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
|
||||
if *strike {
|
||||
self.rect_uv(x0, py + 3.5, px + *advance - x0, 1.0, white, col);
|
||||
}
|
||||
if underline {
|
||||
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
|
||||
if *underline {
|
||||
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
|
||||
}
|
||||
lift(self, start, base + 0.01);
|
||||
}
|
||||
}
|
||||
cursor - x
|
||||
}
|
||||
|
||||
fn draw_inline_object(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
object: &crate::text::InlineObject,
|
||||
x: f32,
|
||||
y: f32,
|
||||
alpha: u8,
|
||||
) {
|
||||
use crate::text::InlineObject;
|
||||
let tint = rgba(255, 255, 255, alpha);
|
||||
match object {
|
||||
InlineObject::Player { profile, hat, .. } => {
|
||||
let id = crate::skins::parse_profile(profile).id;
|
||||
let name = inline_profile_name(profile);
|
||||
let by_id = atlas.object_skins.get(&format!("id:{id:032x}"));
|
||||
let by_name = name.and_then(|name| {
|
||||
atlas
|
||||
.object_skins
|
||||
.get(&format!("name:{}", name.to_ascii_lowercase()))
|
||||
});
|
||||
let resolved = if profile.get("id").is_some() {
|
||||
by_id.or(by_name)
|
||||
} else {
|
||||
by_name.or(by_id)
|
||||
};
|
||||
let default = crate::tab_list::default_skin(id);
|
||||
let skin = resolved
|
||||
.filter(|path| atlas.sprites.contains_key(*path))
|
||||
.map_or(default, String::as_str);
|
||||
let Some(rect) = atlas.sprites.get(skin).copied() else {
|
||||
return;
|
||||
};
|
||||
for u in [8.0, 40.0].into_iter().take(if *hat { 2 } else { 1 }) {
|
||||
let du = (rect.u1 - rect.u0) / 64.0;
|
||||
let dv = (rect.v1 - rect.v0) / 64.0;
|
||||
self.rect_uv(
|
||||
x,
|
||||
y,
|
||||
8.0,
|
||||
8.0,
|
||||
Rect {
|
||||
u0: rect.u0 + du * u,
|
||||
v0: rect.v0 + dv * 8.0,
|
||||
u1: rect.u0 + du * (u + 8.0),
|
||||
v1: rect.v0 + dv * 16.0,
|
||||
w: 8,
|
||||
h: 8,
|
||||
},
|
||||
tint,
|
||||
);
|
||||
}
|
||||
}
|
||||
InlineObject::Atlas { sprite, .. } => {
|
||||
let key = sprite.strip_prefix("minecraft:").unwrap_or(sprite);
|
||||
if let Some(rect) = atlas.sprites.get(key).copied() {
|
||||
self.rect_uv(x, y, 8.0, 8.0, rect, tint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
|
||||
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
|
||||
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
|
||||
@@ -1190,6 +1304,13 @@ impl HudBatch {
|
||||
behind
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_profile_name(profile: &serde_json::Value) -> Option<&str> {
|
||||
profile
|
||||
.as_str()
|
||||
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str))
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct HudUniform {
|
||||
@@ -1904,6 +2025,56 @@ mod tests {
|
||||
assert!(missing.rect.is_none() && missing.right == 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_object_draws_an_inline_head() {
|
||||
let root = two_glyph_font("inline-head");
|
||||
write_png(&root, "entity/player/slim/alex", 64, 64);
|
||||
write_png(&root, "entity/player/wide/steve", 64, 64);
|
||||
let mut atlas = HudAtlas::build(&root).unwrap();
|
||||
let text = crate::component::to_text(
|
||||
&serde_json::json!({"object":"player","player":{"id":"00000000-0000-0000-0000-000000000000"}}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
assert_eq!(
|
||||
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false),
|
||||
8.0
|
||||
);
|
||||
assert_eq!(batch.vertices.len(), 12); // face and hat, six vertices each
|
||||
assert_eq!(batch.inline_profiles.len(), 1);
|
||||
atlas.set_object_skin("name:elyapluggin".into(), "entity/player/wide/steve".into());
|
||||
let named = crate::component::to_text(
|
||||
&serde_json::json!({"object":"player","player":{"name":"elyapluggin"}}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
batch.vertices.clear();
|
||||
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
|
||||
let skin = atlas.sprites["entity/player/wide/steve"];
|
||||
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atlas_object_draws_an_item_sprite_inline() {
|
||||
let root = two_glyph_font("inline-item");
|
||||
write_png(&root, "item/porkchop", 16, 16);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let text = crate::component::to_text(
|
||||
&serde_json::json!({
|
||||
"text": "a",
|
||||
"extra": [{"object": "atlas", "sprite": "minecraft:item/porkchop"}]
|
||||
}),
|
||||
&mut |_| crate::text::Text::empty(),
|
||||
);
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
let width = batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
|
||||
assert_eq!(width, atlas.font.glyph('a').advance + 8.0);
|
||||
assert_eq!(batch.vertices.len(), 12); // glyph and one sprite
|
||||
let sprite = atlas.sprites["item/porkchop"];
|
||||
assert_eq!(batch.vertices[6].uv, [sprite.u0, sprite.v0]);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
|
||||
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
|
||||
@@ -2149,6 +2320,7 @@ mod tests {
|
||||
end_portal: EndPortalFill::default(),
|
||||
glint_alpha: 0.75,
|
||||
blur_at: 0,
|
||||
inline_profiles: Vec::new(),
|
||||
};
|
||||
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
|
||||
assert_eq!(
|
||||
|
||||
@@ -883,6 +883,24 @@ fn relight_layer(
|
||||
let Some(old) = level(world, sky, p) else {
|
||||
continue;
|
||||
};
|
||||
// `BlockLightEngine.checkNode`: a block emitting at least the light
|
||||
// already there keeps it and pulls its neighbours' light in. Clearing
|
||||
// it would darken and relight everything it lit (flowing lava next to
|
||||
// lava: thousands of cells per block).
|
||||
if !sky && !force && old > 0 {
|
||||
let emission = world
|
||||
.state(p.0, p.1, p.2)
|
||||
.map_or(0, |s| tables.cells[s as usize].emission);
|
||||
if emission >= old {
|
||||
if emission > old {
|
||||
set_level(world, sky, p, emission);
|
||||
changed.push(p);
|
||||
}
|
||||
decrease.push_back((p, 0));
|
||||
increase.push_back(p);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if old > 0 {
|
||||
set_level(world, sky, p, 0);
|
||||
changed.push(p);
|
||||
@@ -1089,6 +1107,49 @@ mod tests {
|
||||
assert!(same(&w, (1, 0)));
|
||||
assert_eq!(block(&w, 14, 0, 8), 13);
|
||||
}
|
||||
/// Emitters placed in light at most as bright as theirs (flowing lava
|
||||
/// next to lava) only brighten: nothing they lit is darkened and lit
|
||||
/// again, and the result is the full relight's.
|
||||
#[test]
|
||||
fn emitters_placed_in_their_own_light_only_brighten() {
|
||||
let mut w = world(|c| {
|
||||
c.set(8, 0, 8, TORCH);
|
||||
});
|
||||
let block = |w: &HashMap<(i32, i32), Arc<Chunk>>, p: Pos| {
|
||||
w[&(p.0 >> 4, p.2 >> 4)].light(p.0 & 15, p.1, p.2 & 15) & 15
|
||||
};
|
||||
for (x, y, z) in [(9, 0, 8), (10, 0, 8), (10, 1, 8), (11, 1, 9), (8, 0, 9)] {
|
||||
let around: Vec<Pos> = (-15..=15)
|
||||
.flat_map(|dx| {
|
||||
(-3..=3).flat_map(move |dy| (-15..=15).map(move |dz| (x + dx, y + dy, z + dz)))
|
||||
})
|
||||
.collect();
|
||||
let before: Vec<u8> = around.iter().map(|&p| block(&w, p)).collect();
|
||||
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, TORCH);
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
|
||||
for (&p, &was) in around.iter().zip(&before) {
|
||||
assert!(block(&w, p) >= was, "{p:?} darkened from {was}");
|
||||
}
|
||||
}
|
||||
// An emitter checked again with its block light unchanged changes
|
||||
// nothing, so no section is remeshed.
|
||||
let mut changed = LightChanges::default();
|
||||
relight(&mut w, &tables(), false, &[(8, 0, 8)], false, &mut changed);
|
||||
assert!(changed.is_empty());
|
||||
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
|
||||
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
|
||||
});
|
||||
for (si, s) in light_chunk(®ion, &tables(), true).iter().enumerate() {
|
||||
for i in 0..4096 {
|
||||
assert_eq!(
|
||||
s.get(i),
|
||||
w[&(0, 0)].sections[si].light(i),
|
||||
"section {si} index {i}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn random_border_relights_match_full_relight() {
|
||||
// Edits around every border of chunk (0, 0), at the top and bottom of
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -669,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, "");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -220,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.
|
||||
@@ -461,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()
|
||||
@@ -551,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")
|
||||
@@ -566,7 +603,6 @@ impl Streaming {
|
||||
Self {
|
||||
shared,
|
||||
light_view,
|
||||
results,
|
||||
threads,
|
||||
order: 0,
|
||||
scan_age: 0,
|
||||
@@ -930,10 +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());
|
||||
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.collect::<Vec<_>>()) {
|
||||
for done in deferred.into_iter().chain(received) {
|
||||
match done {
|
||||
Done::Proto(x, z, proto, decorated) => {
|
||||
self.generation_scan.exhausted = false;
|
||||
@@ -1444,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;
|
||||
}
|
||||
@@ -1471,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),
|
||||
@@ -1556,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);
|
||||
}
|
||||
}
|
||||
@@ -1837,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);
|
||||
@@ -1878,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();
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -234,6 +234,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
|
||||
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
|
||||
"LookForPlayer" => "EndermanLookForPlayerGoal",
|
||||
"HealRaiders" => "NearestHealableRaiderTargetGoal",
|
||||
"MountPanic" => "MountPanicGoal",
|
||||
"LlamaHurtBy" => "LlamaHurtByTargetGoal",
|
||||
other => return other.to_owned(),
|
||||
};
|
||||
vanilla.to_owned()
|
||||
|
||||
@@ -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,10 +165,10 @@ mod screens;
|
||||
mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_links;
|
||||
mod server_mods;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod shelf;
|
||||
mod shield;
|
||||
mod shoulders;
|
||||
mod sign_edit;
|
||||
@@ -326,15 +321,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 +390,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 +405,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 +421,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 +436,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 +549,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 +1430,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>,
|
||||
@@ -1924,6 +1943,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).
|
||||
@@ -4178,9 +4200,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);
|
||||
@@ -4382,6 +4401,14 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(swings) = self.use_shelf(hit) {
|
||||
let used = held.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
|
||||
self.trigger_block("minecraft:item_used_on_block", hit.pos, &used);
|
||||
if swings {
|
||||
self.swing_arm();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if let Some(swings) = self.use_chiseled_bookshelf(hit, held.as_ref()) {
|
||||
if swings {
|
||||
self.swing_arm();
|
||||
@@ -4643,7 +4670,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);
|
||||
@@ -5285,9 +5311,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() {
|
||||
@@ -5309,6 +5334,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.
|
||||
@@ -5361,7 +5387,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);
|
||||
@@ -6164,6 +6190,7 @@ impl Game<'_> {
|
||||
self.frame_graph
|
||||
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
|
||||
self.draw_pad_cursor(&mut batch);
|
||||
self.resolve_inline_profiles(&batch.inline_profiles);
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
@@ -6177,11 +6204,6 @@ impl Game<'_> {
|
||||
self.draw_options(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
// A mod's settings page opened from Options > Mods at the title.
|
||||
if self.screen == Screen::Mod && self.title.is_some() {
|
||||
self.draw_mod_screen(&mut batch);
|
||||
return batch;
|
||||
}
|
||||
if let Some(title) = &mut self.title {
|
||||
title.poll_backup();
|
||||
if let Some(sound) = title.update_toast(self.settings.notification_display_time) {
|
||||
@@ -6274,7 +6296,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
|
||||
@@ -6453,7 +6474,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);
|
||||
@@ -6521,7 +6541,9 @@ impl Game<'_> {
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_),
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
| BlockEntity::Shelf(_),
|
||||
) => {}
|
||||
None => {
|
||||
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
|
||||
@@ -6805,7 +6827,9 @@ impl Game<'_> {
|
||||
| BlockEntity::Skull(_)
|
||||
| BlockEntity::DaylightDetector
|
||||
| BlockEntity::Campfire(_)
|
||||
| BlockEntity::ChiseledBookshelf(_),
|
||||
| BlockEntity::ChiseledBookshelf(_)
|
||||
| BlockEntity::Brushable(_)
|
||||
| BlockEntity::Shelf(_),
|
||||
) => {}
|
||||
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
|
||||
None => {
|
||||
@@ -6947,6 +6971,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),
|
||||
_ => {}
|
||||
}
|
||||
@@ -8282,6 +8309,8 @@ impl Game<'_> {
|
||||
// `CampfireRenderer`.
|
||||
self.mark_entity(out.len(), "");
|
||||
self.campfire_geometry(camera, &mut out);
|
||||
self.brushable_geometry(camera, &mut out);
|
||||
self.shelf_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
// Mobs and their projectiles (a span each).
|
||||
@@ -9356,6 +9385,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();
|
||||
@@ -9682,10 +9717,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();
|
||||
@@ -9711,6 +9748,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(())
|
||||
@@ -10692,10 +10730,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,
|
||||
@@ -11021,6 +11062,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,
|
||||
@@ -11849,7 +11893,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.
|
||||
@@ -11942,7 +12038,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 = [
|
||||
@@ -12397,7 +12492,7 @@ impl Game<'_> {
|
||||
|| self.remote_loading();
|
||||
let blurred = loading
|
||||
|| self.title.as_ref().is_some_and(|t| {
|
||||
matches!(self.screen, Screen::Options | Screen::Mod | Screen::Win)
|
||||
matches!(self.screen, Screen::Options | Screen::Win)
|
||||
|| !matches!(t.mode, horizon_client::title::Mode::Main)
|
||||
})
|
||||
|| (self.title.is_none()
|
||||
@@ -12456,9 +12551,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(());
|
||||
}
|
||||
@@ -12496,7 +12588,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
|
||||
@@ -12626,10 +12717,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(());
|
||||
}
|
||||
@@ -12697,19 +12784,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 {
|
||||
@@ -13218,7 +13297,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)
|
||||
@@ -13244,12 +13322,12 @@ 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);
|
||||
}
|
||||
// AbstractContainerScreen.mouseScrolled: a bundle under the
|
||||
// cursor takes the wheel before the screen's own scrolling.
|
||||
let bundle = bundles::takes_bundle_wheel(self.screen)
|
||||
@@ -13311,13 +13389,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
|
||||
WindowEvent::MouseInput { state, button, .. }
|
||||
if self.screen == Screen::Mod && self.title.is_some() =>
|
||||
{
|
||||
if state == ElementState::Pressed {
|
||||
self.mod_screen_click(button);
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
|
||||
if self.options_waiting_for_key() && state == ElementState::Pressed {
|
||||
self.options_bind_mouse(button)?;
|
||||
@@ -13439,7 +13510,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)
|
||||
@@ -13542,7 +13612,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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -13688,16 +13757,6 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.options_key(key, event.text.as_deref())?;
|
||||
}
|
||||
}
|
||||
// A mod's settings page at the title takes its keys like Options.
|
||||
WindowEvent::KeyboardInput { event, .. }
|
||||
if self.screen == Screen::Mod && self.title.is_some() =>
|
||||
{
|
||||
if event.state == ElementState::Pressed
|
||||
&& let PhysicalKey::Code(key) = event.physical_key
|
||||
{
|
||||
self.key_pressed(key)?;
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => {
|
||||
if event.state == ElementState::Pressed {
|
||||
if let Some(shortcut) = self.edit_shortcut(&event) {
|
||||
@@ -13749,7 +13808,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
|
||||
@@ -13854,12 +13912,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();
|
||||
@@ -13913,6 +13970,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
let mut consumed = false;
|
||||
let finished = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
@@ -13921,8 +13979,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(
|
||||
@@ -13934,6 +13993,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);
|
||||
@@ -14455,6 +14521,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(),
|
||||
@@ -15334,7 +15401,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()?;
|
||||
@@ -15413,6 +15479,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);
|
||||
}
|
||||
}
|
||||
@@ -15675,7 +15742,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,
|
||||
@@ -15950,7 +16017,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(
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -153,5 +153,6 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.update_spawner_display(key);
|
||||
}
|
||||
}
|
||||
@@ -801,6 +801,7 @@ impl Game<'_> {
|
||||
self.screen = super::Screen::Inventory;
|
||||
self.capture(false);
|
||||
self.right = false;
|
||||
self.swing_arm();
|
||||
return true;
|
||||
}
|
||||
if sneaking
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
|
||||
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
|
||||
//! entity (like a decorated pot's) until the first counted stroke rolls it.
|
||||
use super::{Game, Hit, decorated_pot, spill, view_vector};
|
||||
use super::{Game, Hit, decorated_pot, view_vector};
|
||||
use horizon_client::{
|
||||
block_entity_render::vertex_light,
|
||||
containers::BlockEntity,
|
||||
mesher::Vertex,
|
||||
particles::{ParticleOptions, ParticleType},
|
||||
save,
|
||||
};
|
||||
@@ -114,6 +117,20 @@ impl Game<'_> {
|
||||
let time = self.world.game_time;
|
||||
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
|
||||
brush.hit_face.get_or_insert(face);
|
||||
let direction = match brush.hit_face.unwrap() {
|
||||
[0, -1, 0] => 0,
|
||||
[0, 1, 0] => 1,
|
||||
[0, 0, -1] => 2,
|
||||
[0, 0, 1] => 3,
|
||||
[-1, 0, 0] => 4,
|
||||
[1, 0, 0] => 5,
|
||||
_ => return false,
|
||||
};
|
||||
if let Some(BlockEntity::Brushable(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.hit_direction = Some(direction);
|
||||
}
|
||||
brush.resets_at = time + 40;
|
||||
if time < brush.cooldown_ends_at {
|
||||
self.brushing.insert(pos, brush);
|
||||
@@ -139,13 +156,19 @@ impl Game<'_> {
|
||||
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
|
||||
/// block entity's item.
|
||||
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
|
||||
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get(&key) else {
|
||||
return;
|
||||
};
|
||||
let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") else {
|
||||
let Some(horizon_world::nbt::Tag::String(table)) = &b.loot_table else {
|
||||
return;
|
||||
};
|
||||
let table = table.to_string_lossy();
|
||||
let mut tag = save::encode_block_entity(
|
||||
key,
|
||||
&BlockEntity::Brushable(b.clone()),
|
||||
"minecraft:brushable_block",
|
||||
);
|
||||
self.trigger_loot_table(&table);
|
||||
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
|
||||
decorated_pot::unpack_loot(
|
||||
@@ -155,7 +178,12 @@ impl Game<'_> {
|
||||
&mut self.rand_sequences,
|
||||
self.level.seed,
|
||||
);
|
||||
spill::put_saved_entity(&mut self.world, pos, tag);
|
||||
if let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get_mut(&key) {
|
||||
b.loot_table = tag.get("LootTable").cloned();
|
||||
b.loot_seed = tag.get("LootTableSeed").cloned();
|
||||
b.item = tag.get("item").and_then(save::item_from_nbt);
|
||||
}
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
|
||||
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
|
||||
@@ -168,8 +196,10 @@ impl Game<'_> {
|
||||
_ => "minecraft:air",
|
||||
};
|
||||
// dropContent: the item at the middle of the block beside that face.
|
||||
let item = spill::saved_entity(&self.world, pos)
|
||||
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
|
||||
let item = match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
Some(BlockEntity::Brushable(b)) => b.item.clone(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(mut item) = item {
|
||||
let face = face.unwrap_or([0, 1, 0]);
|
||||
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
|
||||
@@ -223,12 +253,70 @@ impl Game<'_> {
|
||||
if brush.count != 0 {
|
||||
self.brushing.insert(pos, brush);
|
||||
self.schedule_brushable_tick(pos);
|
||||
} else if let Some(BlockEntity::Brushable(b)) =
|
||||
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
|
||||
{
|
||||
b.hit_direction = None;
|
||||
}
|
||||
if before != after {
|
||||
self.set_dusted(pos, after);
|
||||
}
|
||||
}
|
||||
|
||||
/// `BrushableBlockRenderer`: reveal the item from the first brushed face.
|
||||
pub(super) fn brushable_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Brushable(b) = entity else {
|
||||
continue;
|
||||
};
|
||||
let (Some(item), Some(face)) = (&b.item, b.hit_direction) else {
|
||||
continue;
|
||||
};
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
let state = self.world.block(key.0, key.1, key.2);
|
||||
let progress = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("dusted"))
|
||||
.and_then(|s| s.parse::<u8>().ok())
|
||||
.unwrap_or(0);
|
||||
if progress == 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = f32::from(progress) * 0.075;
|
||||
let (translation, adjacent, east_west) = match face {
|
||||
0 => (glam::Vec3::new(0.5, -0.23 - offset, 0.5), [0, -1, 0], false),
|
||||
1 => (glam::Vec3::new(0.5, 0.25 + offset, 0.5), [0, 1, 0], false),
|
||||
2 => (glam::Vec3::new(0.5, 0.0, 0.25 - offset), [0, 0, -1], false),
|
||||
3 => (glam::Vec3::new(0.5, 0.0, 0.73 + offset), [0, 0, 1], false),
|
||||
4 => (glam::Vec3::new(0.25 - offset, 0.0, 0.5), [-1, 0, 0], true),
|
||||
5 => (glam::Vec3::new(0.73 + offset, 0.0, 0.5), [1, 0, 0], true),
|
||||
_ => continue,
|
||||
};
|
||||
let item = self.resolve_item(item, DisplayContext::Fixed, None, 0);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let pose = glam::Mat4::from_translation(
|
||||
(at - camera).as_vec3() + glam::Vec3::new(0.0, 0.5, 0.0) + translation,
|
||||
) * glam::Mat4::from_rotation_y(
|
||||
(86.0_f32 + if east_west { 90.0 } else { 0.0 }).to_radians(),
|
||||
) * glam::Mat4::from_scale(glam::Vec3::splat(0.5));
|
||||
let light = self.world.light(
|
||||
key.0 + adjacent[0],
|
||||
key.1 + adjacent[1],
|
||||
key.2 + adjacent[2],
|
||||
);
|
||||
self.submit_item_world(&item, pose, vertex_light(light), out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
|
||||
/// and break particles.
|
||||
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
|
||||
|
||||
@@ -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::{
|
||||
@@ -479,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(
|
||||
@@ -579,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,
|
||||
@@ -594,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() {
|
||||
@@ -649,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) {
|
||||
@@ -689,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() {
|
||||
@@ -700,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() {
|
||||
@@ -727,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 {
|
||||
@@ -737,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,
|
||||
@@ -746,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) {
|
||||
@@ -777,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() {
|
||||
@@ -922,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);
|
||||
}
|
||||
}
|
||||
@@ -942,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);
|
||||
@@ -1203,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.
|
||||
|
||||
@@ -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,28 +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 types_text = self.types_text();
|
||||
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) && types_text {
|
||||
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) {
|
||||
@@ -590,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`).
|
||||
@@ -605,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,
|
||||
@@ -654,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),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -151,10 +151,14 @@ impl Containers<'_> {
|
||||
});
|
||||
}
|
||||
let entity = self.world.block_entities.get(&pos)?;
|
||||
// A beehive keeps bees, not items, and a campfire cooks its own: neither
|
||||
// is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops
|
||||
// in front of them and a hopper leaves them alone.
|
||||
if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) {
|
||||
// A beehive keeps bees, not items, a campfire cooks its own and
|
||||
// suspicious sand or gravel hides its loot: none is a `Container`
|
||||
// (HopperBlockEntity.getContainerAt), so a dropper drops in front of
|
||||
// them and a hopper leaves them alone.
|
||||
if matches!(
|
||||
entity,
|
||||
BlockEntity::Beehive(_) | BlockEntity::Campfire(_) | BlockEntity::Brushable(_)
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let kind = match (entity, name) {
|
||||
@@ -422,15 +426,17 @@ pub(super) fn shelf_signal(
|
||||
_ => 0,
|
||||
});
|
||||
}
|
||||
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
|
||||
// Only read from the back: a bit for each of the three slots in use.
|
||||
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
|
||||
if facing.map(Direction::opposite) != Some(direction) {
|
||||
return Some(0);
|
||||
}
|
||||
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
|
||||
let Some(BlockEntity::Shelf(shelf)) = world.block_entities.get(&(pos.x, pos.y, pos.z)) else {
|
||||
return Some(0);
|
||||
};
|
||||
Some(
|
||||
items
|
||||
shelf
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(slot, item)| i32::from(item.is_some()) << slot)
|
||||
@@ -473,6 +479,12 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.chiseled_bookshelf_synced(pos);
|
||||
if matches!(
|
||||
self.world.block_entities.get(&pos),
|
||||
Some(BlockEntity::Shelf(_))
|
||||
) {
|
||||
self.mark_block_entity_updated(pos);
|
||||
}
|
||||
// A double chest's other half changes with it.
|
||||
let halves: Vec<Pos> = std::iter::once(pos)
|
||||
.chain(self.chest_other_half(pos))
|
||||
|
||||
@@ -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.
|
||||
@@ -551,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() {
|
||||
@@ -583,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};
|
||||
@@ -656,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,9 +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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
@@ -460,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,
|
||||
@@ -493,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,
|
||||
@@ -504,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
|
||||
@@ -563,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,
|
||||
@@ -574,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 };
|
||||
@@ -881,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 {
|
||||
|
||||
@@ -339,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
|
||||
@@ -348,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();
|
||||
(
|
||||
@@ -862,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) {
|
||||
|
||||
@@ -39,6 +39,7 @@ use horizon_protocol::{
|
||||
text::{Language, plain},
|
||||
};
|
||||
use horizon_world::{
|
||||
level::InteractionResult,
|
||||
nbt::Tag,
|
||||
registry::BlockStateId,
|
||||
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
|
||||
@@ -325,7 +326,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,9 +559,12 @@ 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();
|
||||
}
|
||||
self.gui.atlas.object_skins.clear();
|
||||
self.server_packs_disconnect();
|
||||
self.code_of_conduct = None;
|
||||
self.reset_tutorial();
|
||||
@@ -608,6 +614,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 +759,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 +1016,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 +1061,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 +1257,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 +2890,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
|
||||
@@ -2988,7 +3002,10 @@ impl Game<'_> {
|
||||
let text = |t: &Tag| {
|
||||
let json = horizon_client::scoreboard::tag_to_json(t);
|
||||
let text = horizon_client::scoreboard::comp_text(&json);
|
||||
(!text.plain().is_empty()).then_some(text)
|
||||
text.spans
|
||||
.iter()
|
||||
.any(|span| !span.text.is_empty())
|
||||
.then_some(text)
|
||||
};
|
||||
self.tab_list.header = text(&p.header);
|
||||
self.tab_list.footer = text(&p.footer);
|
||||
@@ -3234,6 +3251,13 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.and_then(|r| r.skins.get(&p.uuid).cloned())
|
||||
.unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned());
|
||||
self.gui
|
||||
.atlas
|
||||
.set_object_skin(format!("id:{:032x}", p.uuid), p.skin.clone());
|
||||
self.gui.atlas.set_object_skin(
|
||||
format!("name:{}", p.name.to_ascii_lowercase()),
|
||||
p.skin.clone(),
|
||||
);
|
||||
}
|
||||
let r = self.remote.as_ref()?;
|
||||
let upside_down = players
|
||||
@@ -3246,6 +3270,32 @@ impl Game<'_> {
|
||||
.collect();
|
||||
Some((players, r.online_mode, upside_down))
|
||||
}
|
||||
|
||||
/// Resolve inline `object:player` profiles through the same skin cache as
|
||||
/// player heads on items. The next frame uses the uploaded atlas texture.
|
||||
pub(super) fn resolve_inline_profiles(&mut self, profiles: &[serde_json::Value]) {
|
||||
use horizon_client::skins;
|
||||
for profile in profiles {
|
||||
let skin = skins::head_skin(
|
||||
profile,
|
||||
&mut self.skin_profiles.borrow_mut(),
|
||||
&mut self.skin_downloads.borrow_mut(),
|
||||
|location| self.gui.atlas.sprites.contains_key(location),
|
||||
);
|
||||
let id = skins::parse_profile(profile).id;
|
||||
self.gui
|
||||
.atlas
|
||||
.set_object_skin(format!("id:{id:032x}"), skin.clone());
|
||||
let name = profile
|
||||
.as_str()
|
||||
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str));
|
||||
if let Some(name) = name {
|
||||
self.gui
|
||||
.atlas
|
||||
.set_object_skin(format!("name:{}", name.to_ascii_lowercase()), skin);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The signed-in account's `textures`, once the services answered.
|
||||
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
|
||||
use horizon_client::skins::{self, NameOrId, Resolve};
|
||||
@@ -3725,8 +3775,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 +3852,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 +3871,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 +3970,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
|
||||
@@ -4898,18 +4950,32 @@ impl Game<'_> {
|
||||
.and_then(|h| self.res.items().get(&h.item))
|
||||
.is_some_and(|i| i.block.is_some());
|
||||
if let Some(hit) = self.pick() {
|
||||
// ShelfBlock.useItemOn consumes a nonempty main-hand click client-side
|
||||
// without predicting the inventory swap or swinging.
|
||||
let shelf_reacts = hand == Hand::Main
|
||||
&& held.is_some()
|
||||
&& !(self.keys.contains(&KeyCode::ShiftLeft)
|
||||
&& (self.stack_in(self.selected).is_some() || self.offhand.is_some()))
|
||||
&& self.shelf_hit(&hit).is_some();
|
||||
let spectator = self.spectator();
|
||||
let (reacts, changes) = if hand == Hand::Main && !spectator {
|
||||
let (result, changes) = if hand == Hand::Main && !spectator {
|
||||
self.remote_block_reacts(&hit, held.as_ref())
|
||||
} else {
|
||||
// useItemOn runs for the off hand too (useWithoutItem doesn't):
|
||||
// RespawnAnchorBlock.useItemOn charges an anchor with its glowstone.
|
||||
// RespawnAnchorBlock.useItemOn charges an anchor with its
|
||||
// glowstone, SUCCESS.
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
|
||||
let charges =
|
||||
!(sneaking && hands_full) && self.anchor_takes(hit.pos, held.as_ref());
|
||||
(charges, DryUse::default())
|
||||
let result = if charges {
|
||||
InteractionResult::Success
|
||||
} else {
|
||||
InteractionResult::Pass
|
||||
};
|
||||
(result, DryUse::default())
|
||||
};
|
||||
let reacts = result.consumes_action();
|
||||
let seq = self.next_sequence();
|
||||
let cursor = [
|
||||
(hit.point[0] - f64::from(hit.pos[0])) as f32,
|
||||
@@ -4937,9 +5003,17 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.use_target = Some(hit.pos);
|
||||
}
|
||||
if shelf_reacts {
|
||||
return true;
|
||||
}
|
||||
if reacts {
|
||||
self.predict_use(&changes, seq);
|
||||
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
|
||||
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
|
||||
// (a chest, barrel, furnace, …). CONSUME does not swing.
|
||||
if result == InteractionResult::Success {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
|
||||
// CampfireBlock.useItemOn succeeded: this hand swings and the
|
||||
// click ends, so a shield in the other hand is not raised.
|
||||
@@ -4978,7 +5052,7 @@ impl Game<'_> {
|
||||
// server's resend of the target it refused is no placement.
|
||||
r.placing = None;
|
||||
}
|
||||
if reacts || placed.is_some() {
|
||||
if placed.is_some() {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
// BlockItem.place returns FAIL when placement is rejected:
|
||||
@@ -5070,20 +5144,20 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
hit: &super::Hit,
|
||||
held: Option<&ItemStack>,
|
||||
) -> (bool, DryUse) {
|
||||
) -> (InteractionResult, DryUse) {
|
||||
// Player.isSecondaryUseActive with something in either hand.
|
||||
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
|
||||
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
|
||||
return (false, DryUse::default());
|
||||
return (InteractionResult::Pass, DryUse::default());
|
||||
}
|
||||
if self.jukebox_takes(hit.pos, held) {
|
||||
return (true, DryUse::default());
|
||||
return (InteractionResult::Success, DryUse::default());
|
||||
}
|
||||
// RespawnAnchorBlock.useItemOn: PASS when the off hand's glowstone is to
|
||||
// charge the anchor, so useWithoutItem doesn't run and the off hand's turn
|
||||
// comes (glowstone in this hand charges it in `use_item_on`).
|
||||
if !self.anchor_takes(hit.pos, held) && self.anchor_takes(hit.pos, self.offhand.as_ref()) {
|
||||
return (false, DryUse::default());
|
||||
return (InteractionResult::Pass, DryUse::default());
|
||||
}
|
||||
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
|
||||
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
|
||||
@@ -5093,7 +5167,7 @@ impl Game<'_> {
|
||||
.hive_state(hit.pos)
|
||||
.is_some_and(|(honey, _)| honey == Some(5))
|
||||
{
|
||||
return (true, DryUse::default());
|
||||
return (InteractionResult::Success, DryUse::default());
|
||||
}
|
||||
use horizon_core::geometry::Direction;
|
||||
let face = match hit.face {
|
||||
@@ -5107,18 +5181,16 @@ impl Game<'_> {
|
||||
let player = self.use_context();
|
||||
self.with_level(|b, level, _| {
|
||||
let mut level = DryLevel::new(level);
|
||||
let reacts = b
|
||||
.use_item_on(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
face,
|
||||
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
|
||||
player,
|
||||
held.map(|s| &*s.item),
|
||||
)
|
||||
.consumes_action();
|
||||
(reacts, (level.changed.into_iter().collect(), level.sounds))
|
||||
let result = b.use_item_on(
|
||||
&mut level,
|
||||
&mut horizon_world::level::Ticks::default(),
|
||||
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
|
||||
face,
|
||||
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
|
||||
player,
|
||||
held.map(|s| &*s.item),
|
||||
);
|
||||
(result, (level.changed.into_iter().collect(), level.sounds))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5883,22 +5955,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 +6520,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 +6624,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 +6943,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"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! The displayed items of `ShelfBlockEntity`.
|
||||
use super::{Game, Hit, machines::dir_from_name};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{block_entity_render::vertex_light, containers::BlockEntity, mesher::Vertex};
|
||||
use horizon_core::{
|
||||
geometry::{Direction, Vec3},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::level::hit_slot;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Slot and facing for a click on a shelf's front face.
|
||||
pub(super) fn shelf_hit(&self, hit: &Hit) -> Option<(usize, Direction, bool)> {
|
||||
let p = hit.pos;
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&(p[0], p[1], p[2])),
|
||||
Some(BlockEntity::Shelf(_))
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let data = self.pack.assets.registry.state(state)?;
|
||||
let facing = data
|
||||
.properties
|
||||
.get("facing")
|
||||
.and_then(|s| dir_from_name(s))?;
|
||||
let face = Direction::ALL
|
||||
.into_iter()
|
||||
.find(|d| d.normal() == hit.face)?;
|
||||
let slot = hit_slot(
|
||||
facing,
|
||||
face,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
|
||||
1,
|
||||
3,
|
||||
)?;
|
||||
Some((
|
||||
slot,
|
||||
facing,
|
||||
data.properties.get("powered").is_some_and(|s| s == "true"),
|
||||
))
|
||||
}
|
||||
|
||||
/// `SideChainPartBlock.getAllBlocksConnectedTo`: left to right, at most three.
|
||||
fn shelf_chain(&self, pos: [i32; 3], facing: Direction) -> Vec<[i32; 3]> {
|
||||
let left = match facing {
|
||||
Direction::North => Direction::East,
|
||||
Direction::East => Direction::South,
|
||||
Direction::South => Direction::West,
|
||||
Direction::West => Direction::North,
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let behaviours = self.res.behaviours();
|
||||
if !behaviours
|
||||
.tag("minecraft:wooden_shelves")
|
||||
.is_some_and(|tag| tag.contains(behaviours.block(state)))
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let mut result = vec![pos];
|
||||
for (dir, end, prepend) in [(left, "left", true), (left.opposite(), "right", false)] {
|
||||
let normal = dir.normal();
|
||||
for step in 1..3 {
|
||||
let p = [pos[0] + normal[0] * step, pos[1], pos[2] + normal[2] * step];
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
let Some(info) = self.pack.assets.registry.state(state) else {
|
||||
break;
|
||||
};
|
||||
let behaviours = self.res.behaviours();
|
||||
let connectable = behaviours
|
||||
.tag("minecraft:wooden_shelves")
|
||||
.is_some_and(|tag| tag.contains(behaviours.block(state)))
|
||||
&& info.properties.get("powered").is_some_and(|s| s == "true")
|
||||
&& info.properties.get("facing").and_then(|s| dir_from_name(s)) == Some(facing);
|
||||
if !connectable {
|
||||
break;
|
||||
}
|
||||
let part = info
|
||||
.properties
|
||||
.get("side_chain")
|
||||
.map(String::as_str)
|
||||
.unwrap_or("unconnected");
|
||||
if part == "center" || part == end {
|
||||
if prepend {
|
||||
result.insert(0, p);
|
||||
} else {
|
||||
result.push(p);
|
||||
}
|
||||
}
|
||||
if part != "center" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// `ShelfBlock.useItemOn`: a single swap, or a connected hotbar swap while powered.
|
||||
pub(super) fn use_shelf(&mut self, hit: &Hit) -> Option<bool> {
|
||||
use horizon_client::first_person::Hand;
|
||||
if self.hand != Hand::Main {
|
||||
return None;
|
||||
}
|
||||
let (slot, facing, powered) = self.shelf_hit(hit)?;
|
||||
let pos = hit.pos;
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if powered {
|
||||
let chain = self.shelf_chain(pos, facing);
|
||||
let mut swapped = false;
|
||||
let count = chain.len();
|
||||
for (i, p) in chain.into_iter().enumerate() {
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for n in 0..3 {
|
||||
let index = 9_i32 - (count as i32 - i as i32) * 3 + n as i32;
|
||||
if !(0..9).contains(&index) {
|
||||
continue;
|
||||
}
|
||||
let index = index as usize;
|
||||
swapped |= self.inventory[index].is_some() || shelf.items[n].is_some();
|
||||
std::mem::swap(&mut self.inventory[index], &mut shelf.items[n]);
|
||||
}
|
||||
self.container_changed(key);
|
||||
let s = self.world.block(p[0], p[1], p[2]);
|
||||
self.post_block_game_event("minecraft:entity_interact", p, None, Some(s));
|
||||
}
|
||||
if !swapped {
|
||||
return Some(false);
|
||||
}
|
||||
self.audio.play(
|
||||
"minecraft:block.shelf.multi_swap",
|
||||
Some(pos.map(|n| f64::from(n) + 0.5)),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
Some(true)
|
||||
} else {
|
||||
let held = self.inventory[self.selected].clone();
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key) else {
|
||||
return None;
|
||||
};
|
||||
let removed = std::mem::replace(&mut shelf.items[slot], held.clone());
|
||||
self.inventory[self.selected] = if self.creative && removed.is_none() {
|
||||
held.clone()
|
||||
} else {
|
||||
removed.clone()
|
||||
};
|
||||
self.container_changed(key);
|
||||
let vibrations = self.inventory[self.selected].as_ref().is_none_or(|stack| {
|
||||
let default = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_none_or(|item| item.interact_vibrations);
|
||||
let Some(data) = stack.data() else {
|
||||
return default;
|
||||
};
|
||||
if data
|
||||
.other
|
||||
.iter()
|
||||
.any(|(key, _)| key == "!minecraft:use_effects")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
data.other
|
||||
.iter()
|
||||
.find(|(key, _)| key == "minecraft:use_effects")
|
||||
.map_or(default, |(_, tag)| {
|
||||
horizon_client::save::number(tag.get("interact_vibrations")).unwrap_or(1)
|
||||
!= 0
|
||||
})
|
||||
});
|
||||
if vibrations {
|
||||
self.post_block_game_event(
|
||||
"minecraft:item_interact_finish",
|
||||
pos,
|
||||
None,
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
let sound = match (held.is_some(), removed.is_some()) {
|
||||
(false, false) => return None,
|
||||
(true, false) => "minecraft:block.shelf.place_item",
|
||||
(false, true) => "minecraft:block.shelf.take_item",
|
||||
(true, true) => "minecraft:block.shelf.single_swap",
|
||||
};
|
||||
self.audio
|
||||
.play(sound, Some(pos.map(|n| f64::from(n) + 0.5)), 1.0, 1.0);
|
||||
Some(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// `ShelfRenderer`: three items face out, aligned to the bottom or centre.
|
||||
pub(super) fn shelf_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
for (key, entity) in &self.world.block_entities {
|
||||
let BlockEntity::Shelf(shelf) = entity else {
|
||||
continue;
|
||||
};
|
||||
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
|
||||
if at.distance_squared(camera) >= 64.0 * 64.0 {
|
||||
continue;
|
||||
}
|
||||
let block = self.world.block(key.0, key.1, key.2);
|
||||
let facing = self
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(block)
|
||||
.and_then(|s| s.properties.get("facing"))
|
||||
.map(String::as_str)
|
||||
.unwrap_or("north");
|
||||
let yaw: f32 = match facing {
|
||||
"south" => 0.0,
|
||||
"west" => -90.0,
|
||||
"north" => -180.0,
|
||||
"east" => -270.0,
|
||||
_ => 180.0,
|
||||
};
|
||||
let packed = BlockPos::new(key.0, key.1, key.2).pack() as u64;
|
||||
let seed = (packed ^ (packed >> 32)) as i32;
|
||||
let light = vertex_light(self.world.light(key.0, key.1, key.2));
|
||||
for (slot, stack) in shelf.items.iter().enumerate() {
|
||||
let Some(stack) = stack else { continue };
|
||||
let item = self.resolve_item(
|
||||
stack,
|
||||
DisplayContext::OnShelf,
|
||||
None,
|
||||
seed.wrapping_add(slot as i32),
|
||||
);
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (lo, hi) = item.bounding_box();
|
||||
let offset_y = -lo.y
|
||||
- if shelf.align_to_bottom {
|
||||
0.0
|
||||
} else {
|
||||
(hi.y - lo.y) / 2.0
|
||||
};
|
||||
let pose =
|
||||
glam::Mat4::from_translation((at - camera).as_vec3() + glam::Vec3::splat(0.5))
|
||||
* glam::Mat4::from_rotation_y(yaw.to_radians())
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(
|
||||
(slot as f32 - 1.0) * 0.3125,
|
||||
if shelf.align_to_bottom { -0.25 } else { 0.0 },
|
||||
-0.25,
|
||||
))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(0.25))
|
||||
* glam::Mat4::from_translation(glam::Vec3::new(0.0, offset_y, 0.0));
|
||||
self.submit_item_world(&item, pose, light, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -320,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();
|
||||
}
|
||||
}
|
||||
@@ -336,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());
|
||||
}
|
||||
@@ -421,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 => {}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -940,6 +940,23 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
|
||||
match entity {
|
||||
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
|
||||
BlockEntity::Spawner(s) => Some(s.update_tag()),
|
||||
BlockEntity::Brushable(b) => {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(face) = b.hit_direction {
|
||||
entries.push(("hit_direction", Tag::Byte(face as i8)));
|
||||
}
|
||||
if let Some(item) = &b.item {
|
||||
entries.push(("item", crate::save::item_to_nbt(item)));
|
||||
}
|
||||
Some(Tag::compound(entries))
|
||||
}
|
||||
BlockEntity::Shelf(s) => Some(Tag::compound([
|
||||
("Items", crate::save::encode_items(&s.items)),
|
||||
(
|
||||
"align_items_to_bottom",
|
||||
Tag::Byte(i8::from(s.align_to_bottom)),
|
||||
),
|
||||
])),
|
||||
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
|
||||
// the custom name and the components.
|
||||
BlockEntity::Banner(b) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2313,6 +2313,10 @@ impl Mob {
|
||||
{
|
||||
aquatic::spawn_ink(self);
|
||||
}
|
||||
// AbstractHorse.hurtServer: the rear roll after the hurt.
|
||||
if equine::is_equine(self.species()) {
|
||||
equine::after_hurt_server(self);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A splash or cloud's harming effect on this mob: the potion's
|
||||
@@ -2448,6 +2452,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 {
|
||||
@@ -500,6 +507,13 @@ pub fn tick(
|
||||
if m.species() == Species::Endermite {
|
||||
endermite_lifetime(m);
|
||||
}
|
||||
// AbstractHorse.tick runs the stand counter down with or without AI.
|
||||
if super::equine::is_equine(m.species())
|
||||
&& !super::camel::is_camel(m.species())
|
||||
&& !super::llama::is_llama(m.species())
|
||||
{
|
||||
super::equine::stand_tick(m);
|
||||
}
|
||||
} else {
|
||||
ai_step(m, w, level_random, events);
|
||||
}
|
||||
@@ -1205,7 +1219,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 +1264,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 +2309,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
|
||||
|
||||
@@ -304,15 +304,27 @@ impl Goal for RunAroundLikeCrazy {
|
||||
make_mad(m, cx.events);
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.standIfPossible`: `setStanding(20)` (which also stops the
|
||||
/// eating) when `canPerformRearing`.
|
||||
fn stand_if_possible(m: &mut Mob) {
|
||||
if can_rear(m.species()) {
|
||||
m.extra.counter = 20;
|
||||
m.extra.flag = false;
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.hurtServer`, after the hurt went through: one hit in three
|
||||
/// rears the horse. Llamas and camels make the draw too, but never rear.
|
||||
pub fn after_hurt_server(m: &mut Mob) {
|
||||
if m.random.next_int_bound(3).unwrap_or(1) == 0 {
|
||||
stand_if_possible(m);
|
||||
}
|
||||
}
|
||||
/// `AbstractHorse.makeMad`: rear with the angry sound (`makeSound`).
|
||||
pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
if m.extra.counter > 0 {
|
||||
return;
|
||||
}
|
||||
if can_rear(m.species()) {
|
||||
m.extra.counter = 20;
|
||||
m.extra.flag = false;
|
||||
}
|
||||
stand_if_possible(m);
|
||||
// getAngrySound: null (silent) for the skeleton horse.
|
||||
let sound = match m.species() {
|
||||
Species::SkeletonHorse => return,
|
||||
@@ -663,10 +675,15 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.extra.flag = false;
|
||||
}
|
||||
}
|
||||
stand_tick(m);
|
||||
animate(m);
|
||||
}
|
||||
/// `AbstractHorse.tick`: the stand counter runs down to `clearStanding`. It is
|
||||
/// in `tick`, not `aiStep`, so a horse with no AI stops rearing too.
|
||||
pub fn stand_tick(m: &mut Mob) {
|
||||
if m.extra.counter > 0 {
|
||||
m.extra.counter -= 1;
|
||||
}
|
||||
animate(m);
|
||||
}
|
||||
/// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the
|
||||
/// eating and standing flags (`flag` and `counter > 0`).
|
||||
@@ -856,10 +873,13 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mobs::Damage;
|
||||
use crate::mobs::finalize::base_value;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -889,4 +909,50 @@ mod tests {
|
||||
finalize(&mut m);
|
||||
assert_eq!(base_value(&m, Attr::MovementSpeed), m.attrs.speed);
|
||||
}
|
||||
|
||||
fn equine(id: u32, species: Species, name: &str) -> Mob {
|
||||
let mut kind = kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = name.into();
|
||||
k.species = species;
|
||||
k.max_health = 30.0;
|
||||
Mob::new(id, kind, Vec3::new(f64::from(id), 64.0, 0.0), 0.0)
|
||||
}
|
||||
/// Hits `n` fresh mobs of a species once each; how many reared.
|
||||
fn rears(species: Species, name: &str, n: u32) -> u32 {
|
||||
(1..=n)
|
||||
.filter(|&id| {
|
||||
let mut m = equine(id, species, name);
|
||||
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 0.0])));
|
||||
m.extra.counter == 20
|
||||
})
|
||||
.count() as u32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hurt_horse_rears_one_hit_in_three() {
|
||||
// AbstractHorse.hurtServer: nextInt(3) == 0 -> standIfPossible (#3432).
|
||||
let n = rears(Species::Horse, "minecraft:horse", 300);
|
||||
assert!((60..=140).contains(&n), "{n} of 300 hits reared");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rear_ends_after_twenty_ticks() {
|
||||
// AbstractHorse.tick: standCounter runs down, then clearStanding.
|
||||
let mut m = equine(1, Species::Horse, "minecraft:horse");
|
||||
stand_if_possible(&mut m);
|
||||
for _ in 0..19 {
|
||||
stand_tick(&mut m);
|
||||
}
|
||||
assert!(m.extra.counter > 0);
|
||||
stand_tick(&mut m);
|
||||
assert_eq!(m.extra.counter, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn llamas_and_camels_never_rear_when_hurt() {
|
||||
// Llama and Camel: canPerformRearing is false.
|
||||
assert_eq!(rears(Species::Llama, "minecraft:llama", 60), 0);
|
||||
assert_eq!(rears(Species::Camel, "minecraft:camel", 60), 0);
|
||||
}
|
||||
}
|
||||
@@ -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]],
|
||||
|
||||
@@ -1233,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;
|
||||
@@ -1246,6 +1246,7 @@ pub fn render_with(
|
||||
light,
|
||||
data,
|
||||
extra,
|
||||
true,
|
||||
out,
|
||||
);
|
||||
}
|
||||
@@ -1291,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(
|
||||
@@ -1303,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(
|
||||
@@ -1314,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));
|
||||
@@ -1327,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 ---------------------------------------------------------------
|
||||
|
||||
@@ -705,6 +705,14 @@ impl PlayerMotion {
|
||||
/// Vehicle, item-use and effect bindings remain separate; hunger, flight,
|
||||
/// water and collision conditions use this actor's actual state.
|
||||
pub fn sprint_input_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
|
||||
self.sprint_input_tick_with_window(keys, 7)
|
||||
}
|
||||
/// The same phase with Java's `Options.sprintWindow` value.
|
||||
pub fn sprint_input_tick_with_window(
|
||||
&mut self,
|
||||
keys: crate::sprint::Keys,
|
||||
sprint_window: i32,
|
||||
) -> Result<()> {
|
||||
let conditions = crate::sprint::Conditions {
|
||||
sprinting: self.sprinting,
|
||||
enough_food: self.health.food.has_enough_food(),
|
||||
@@ -725,7 +733,9 @@ impl PlayerMotion {
|
||||
// The sprint setter edits attributes and flags, independent of local
|
||||
// keyboard history. Retain the input prefix even if that setter fails.
|
||||
let mut state = self.sprint_input;
|
||||
let result = state.tick(keys, conditions, 7, |value| self.set_sprinting(value));
|
||||
let result = state.tick(keys, conditions, sprint_window.clamp(0, 10), |value| {
|
||||
self.set_sprinting(value)
|
||||
});
|
||||
self.sprint_input = state;
|
||||
result
|
||||
}
|
||||
@@ -992,6 +1002,14 @@ impl MotionWorld<'_, '_, '_> {
|
||||
.food_tick(&self.resources.health, self.scene.health_rules)
|
||||
}
|
||||
pub fn controlled_simulation_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
|
||||
self.controlled_simulation_tick_with_sprint_window(keys, 7)
|
||||
}
|
||||
/// Local simulation with the configured double-tap sprint window.
|
||||
pub fn controlled_simulation_tick_with_sprint_window(
|
||||
&mut self,
|
||||
keys: crate::sprint::Keys,
|
||||
sprint_window: i32,
|
||||
) -> Result<()> {
|
||||
// The base tick (fluids, swimming) precedes LocalPlayer.aiStep.
|
||||
self.player.health.advance_clock();
|
||||
self.fluid_base_tick()?;
|
||||
@@ -1004,7 +1022,8 @@ impl MotionWorld<'_, '_, '_> {
|
||||
self.player.auto_jump_time -= 1;
|
||||
keys.jump = true;
|
||||
}
|
||||
self.player.sprint_input_tick(keys)?;
|
||||
self.player
|
||||
.sprint_input_tick_with_window(keys, sprint_window)?;
|
||||
self.player.shift_key_down = keys.shift;
|
||||
let direction = keys.direction();
|
||||
self.player.move_vector = direction;
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
//! Extras that became official mods (docs/mods.md, "Horizon's official
|
||||
//! mods"). For a player who had one on, the game installs the mod from
|
||||
//! Horizon's registry once, checked as the launcher checks it, turns it on
|
||||
//! and carries the extra's settings over into the mod's storage. A player
|
||||
//! who had it off gets nothing: the mod is in the mod browser.
|
||||
use crate::{extras::Extras, mod_list::ModList};
|
||||
use horizon_mods::index::{self, Index};
|
||||
use std::{
|
||||
fs,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// Horizon's registry; `HORIZON_MOD_INDEX` points elsewhere (a local file
|
||||
/// for trials, whose files may then be `file://`), as for the launcher.
|
||||
pub const INDEX_URL: &str = "https://horizon-rust.github.io/horizon-mods/index.json";
|
||||
const MAX_INDEX: u64 = 16 << 20;
|
||||
|
||||
/// An extra that is an official mod now.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Moved {
|
||||
/// The mod's id in the registry.
|
||||
pub id: &'static str,
|
||||
/// The extra's name, for the line the player reads.
|
||||
pub name: &'static str,
|
||||
}
|
||||
|
||||
pub const MOVED: [Moved; 4] = [
|
||||
Moved {
|
||||
id: "fps_overlay",
|
||||
name: "FPS counter",
|
||||
},
|
||||
Moved {
|
||||
id: "keystrokes",
|
||||
name: "Keystrokes",
|
||||
},
|
||||
Moved {
|
||||
id: "zoom",
|
||||
name: "Zoom",
|
||||
},
|
||||
Moved {
|
||||
id: "minimap",
|
||||
name: "Minimap",
|
||||
},
|
||||
];
|
||||
|
||||
impl Moved {
|
||||
fn was_on(&self, e: &Extras) -> bool {
|
||||
match self.id {
|
||||
"fps_overlay" => e.fps,
|
||||
"keystrokes" => e.keystrokes,
|
||||
"zoom" => e.zoom,
|
||||
_ => e.minimap,
|
||||
}
|
||||
}
|
||||
/// The extra's settings as the mod keeps them (its storage keys).
|
||||
pub fn settings(&self, e: &Extras) -> Vec<(&'static str, String)> {
|
||||
let flag = |b: bool| if b { "true" } else { "false" }.to_owned();
|
||||
match self.id {
|
||||
"fps_overlay" => vec![
|
||||
("corner", e.fps_corner.name().into()),
|
||||
("lows", flag(e.fps_lows)),
|
||||
],
|
||||
"keystrokes" => vec![
|
||||
("corner", e.keystrokes_corner.name().into()),
|
||||
("cps", flag(e.keystrokes_cps)),
|
||||
],
|
||||
"zoom" => vec![
|
||||
("level", e.zoom_level.to_string()),
|
||||
("smooth", flag(e.zoom_smooth)),
|
||||
("sensitivity", flag(e.zoom_sensitivity)),
|
||||
],
|
||||
_ => vec![
|
||||
("corner", e.minimap_corner.name().into()),
|
||||
("size", e.minimap_size.to_string()),
|
||||
(
|
||||
"shape",
|
||||
if e.minimap_round { "round" } else { "square" }.into(),
|
||||
),
|
||||
("zoom", e.minimap_zoom.to_string()),
|
||||
("rotate", flag(e.minimap_rotate)),
|
||||
],
|
||||
}
|
||||
}
|
||||
/// The extra's key as the mod's key (`horizon-mods.txt`), when the
|
||||
/// player had changed it from the mod's default.
|
||||
pub fn keys(&self, e: &Extras) -> Vec<(&'static str, String)> {
|
||||
match self.id {
|
||||
"zoom" if e.zoom_key != "key.keyboard.z" => vec![("zoom", e.zoom_key.clone())],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The line the player reads once it is installed.
|
||||
pub fn notice(&self) -> String {
|
||||
format!("{} moved to the mod browser: installed for you", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
/// The moved extras still to install: on, and not done yet.
|
||||
pub fn pending(e: &Extras) -> Vec<&'static Moved> {
|
||||
MOVED
|
||||
.iter()
|
||||
.filter(|m| m.was_on(e) && !e.moved.iter().any(|d| d == m.id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The moved extras that were off and not marked yet: nothing to install,
|
||||
/// only to remember.
|
||||
pub fn nothing_to_do(e: &Extras) -> Vec<&'static Moved> {
|
||||
MOVED
|
||||
.iter()
|
||||
.filter(|m| !m.was_on(e) && !e.moved.iter().any(|d| d == m.id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn location() -> String {
|
||||
std::env::var("HORIZON_MOD_INDEX")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| INDEX_URL.to_owned())
|
||||
}
|
||||
|
||||
/// `url`'s bytes, at most `max`: `https://` from the network; `file://` only
|
||||
/// with a local index (a trial registry).
|
||||
fn get(url: &str, local: bool, max: u64) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
if url.starts_with("https://") {
|
||||
let response = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.get(url)
|
||||
.call()
|
||||
.map_err(|e| format!("{url}: {e}"))?;
|
||||
response
|
||||
.into_reader()
|
||||
.take(max + 1)
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
let path = match url.strip_prefix("file://") {
|
||||
Some(p) if local => p,
|
||||
_ if local && !url.contains("://") => url,
|
||||
_ => return Err(format!("{url}: not an https address")),
|
||||
};
|
||||
out = fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
}
|
||||
if out.len() as u64 > max {
|
||||
return Err(format!("{url}: over {max} bytes"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The registry's index, from the network (or `HORIZON_MOD_INDEX`).
|
||||
pub fn fetch_index() -> Result<Index, String> {
|
||||
let at = location();
|
||||
let local = !at.starts_with("https://");
|
||||
let text = String::from_utf8(get(&at, local, MAX_INDEX)?).map_err(|e| e.to_string())?;
|
||||
if local {
|
||||
Index::parse_local(&text)
|
||||
} else {
|
||||
Index::parse(&text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs `m` into the game folder `dir` (where `options.txt` is), for a
|
||||
/// player whose extras were `e`: its newest version for this game that
|
||||
/// wasn't taken down and that this Horizon's mod API level runs, checked
|
||||
/// against the index (size, SHA-256, signature, manifest), as
|
||||
/// `horizon-mods/<id>.hmod`, turned on, with the extra's settings in its
|
||||
/// storage unless the mod has its own already.
|
||||
pub fn install(index: &Index, m: &Moved, e: &Extras, dir: &Path) -> Result<(), String> {
|
||||
let project = index
|
||||
.project(m.id)
|
||||
.ok_or_else(|| format!("{} isn't in Horizon's registry yet", m.id))?;
|
||||
let version = index
|
||||
.installable(
|
||||
project,
|
||||
horizon_core::version::VERSION_ID,
|
||||
horizon_mods::api_level::LEVEL,
|
||||
)
|
||||
.ok_or_else(|| format!("{} has no version this Horizon can run", m.id))?;
|
||||
let mods = dir.join(crate::mod_list::DIR);
|
||||
fs::create_dir_all(&mods).map_err(|e| format!("{}: {e}", mods.display()))?;
|
||||
let target = mods.join(format!("{}.hmod", m.id));
|
||||
let current = index::sha256_file(&target).ok();
|
||||
if current.as_deref() != Some(version.file.sha256.as_str()) {
|
||||
let local = !location().starts_with("https://");
|
||||
let bytes = get(&version.file.url, local, index::MAX_FILE)?;
|
||||
let partial: PathBuf = mods.join(format!(".{}.hmod.download", m.id));
|
||||
fs::write(&partial, bytes).map_err(|e| format!("{}: {e}", partial.display()))?;
|
||||
if let Err(e) = index::check_file(index, project, version, &partial) {
|
||||
let _ = fs::remove_file(&partial);
|
||||
return Err(format!("{}: {e}", m.id));
|
||||
}
|
||||
fs::rename(&partial, &target).map_err(|e| format!("{}: {e}", target.display()))?;
|
||||
}
|
||||
let storage = mods.join(horizon_mods::manifest::STORAGE_DIR).join(m.id);
|
||||
fs::create_dir_all(&storage).map_err(|e| format!("{}: {e}", storage.display()))?;
|
||||
for (key, value) in m.settings(e) {
|
||||
let path = storage.join(key);
|
||||
if !path.exists() {
|
||||
fs::write(&path, value).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
}
|
||||
}
|
||||
let mut list = ModList::load(dir);
|
||||
list.enabled.insert(m.id.to_owned());
|
||||
// A key the player already chose for the mod stays.
|
||||
let keys = list.keys.entry(m.id.to_owned()).or_default();
|
||||
for (name, key) in m.keys(e) {
|
||||
keys.entry(name.to_owned()).or_insert(key);
|
||||
}
|
||||
list.keys.retain(|_, k| !k.is_empty());
|
||||
list.save(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extras::Corner;
|
||||
|
||||
#[test]
|
||||
fn only_extras_that_were_on_are_installed_and_each_once() {
|
||||
let mut e = Extras::default();
|
||||
assert!(pending(&e).is_empty());
|
||||
assert_eq!(nothing_to_do(&e).len(), 4);
|
||||
e.fps = true;
|
||||
e.fps_corner = Corner::BottomRight;
|
||||
e.fps_lows = true;
|
||||
assert_eq!(pending(&e), [&MOVED[0]]);
|
||||
assert_eq!(
|
||||
MOVED[0].settings(&e),
|
||||
[("corner", "bottom_right".into()), ("lows", "true".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
MOVED[1].settings(&e),
|
||||
[("corner", "top_left".into()), ("cps", "true".into())]
|
||||
);
|
||||
e.moved.push("fps_overlay".into());
|
||||
assert!(pending(&e).is_empty());
|
||||
assert_eq!(nothing_to_do(&e), [&MOVED[1], &MOVED[2], &MOVED[3]]);
|
||||
// Zoom: its settings, and its key only when the player changed it.
|
||||
e.zoom = true;
|
||||
e.zoom_level = 8;
|
||||
e.zoom_smooth = false;
|
||||
assert_eq!(pending(&e), [&MOVED[2]]);
|
||||
assert_eq!(
|
||||
MOVED[2].settings(&e),
|
||||
[
|
||||
("level", "8".into()),
|
||||
("smooth", "false".into()),
|
||||
("sensitivity", "true".into())
|
||||
]
|
||||
);
|
||||
assert!(MOVED[2].keys(&e).is_empty());
|
||||
e.zoom_key = "key.mouse.4".into();
|
||||
assert_eq!(MOVED[2].keys(&e), [("zoom", "key.mouse.4".into())]);
|
||||
// The minimap: its five settings, in the extra's values.
|
||||
e.minimap = true;
|
||||
e.minimap_corner = Corner::BottomLeft;
|
||||
e.minimap_size = 128;
|
||||
e.minimap_round = true;
|
||||
e.minimap_zoom = 0.5;
|
||||
assert_eq!(pending(&e), [&MOVED[2], &MOVED[3]]);
|
||||
assert_eq!(
|
||||
MOVED[3].settings(&e),
|
||||
[
|
||||
("corner", "bottom_left".into()),
|
||||
("size", "128".into()),
|
||||
("shape", "round".into()),
|
||||
("zoom", "0.5".into()),
|
||||
("rotate", "false".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
MOVED[0].notice(),
|
||||
"FPS counter moved to the mod browser: installed for you"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_url_needs_a_local_index() {
|
||||
assert!(get("file:///etc/hosts", false, 1 << 20).is_err());
|
||||
assert!(get("http://example.com/x", true, 1 << 20).is_err());
|
||||
}
|
||||
}
|
||||
@@ -483,6 +483,13 @@ pub fn encode_block_entity(
|
||||
}
|
||||
}
|
||||
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
|
||||
BlockEntity::Shelf(shelf) => {
|
||||
entries.push(("Items", encode_items(&shelf.items)));
|
||||
entries.push((
|
||||
"align_items_to_bottom",
|
||||
Tag::Byte(i8::from(shelf.align_to_bottom)),
|
||||
));
|
||||
}
|
||||
// ChiseledBookShelfBlockEntity.saveAdditional.
|
||||
BlockEntity::ChiseledBookshelf(b) => {
|
||||
entries.push(("Items", encode_items(&b.items)));
|
||||
@@ -547,6 +554,16 @@ pub fn encode_block_entity(
|
||||
BlockEntity::Vault(v) => entries.extend(v.save()),
|
||||
BlockEntity::Beehive(h) => entries.extend(h.to_nbt()),
|
||||
BlockEntity::CommandBlock(c) => entries.extend(c.save()),
|
||||
BlockEntity::Brushable(b) => {
|
||||
if let Some(table) = &b.loot_table {
|
||||
entries.push(("LootTable", table.clone()));
|
||||
if let Some(seed) = &b.loot_seed {
|
||||
entries.push(("LootTableSeed", seed.clone()));
|
||||
}
|
||||
} else if let Some(item) = &b.item {
|
||||
entries.push(("item", item_to_nbt(item)));
|
||||
}
|
||||
}
|
||||
BlockEntity::DaylightDetector => {}
|
||||
}
|
||||
Tag::compound(entries)
|
||||
@@ -724,8 +741,24 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
|
||||
_ => 1.0,
|
||||
};
|
||||
}
|
||||
BlockEntity::Brushable(b) => {
|
||||
b.loot_table = tag.get("LootTable").cloned();
|
||||
b.loot_seed = tag.get("LootTableSeed").cloned();
|
||||
b.item = if b.loot_table.is_none() {
|
||||
tag.get("item").and_then(item_from_nbt)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
b.hit_direction = int("hit_direction").filter(|v| (0..=5).contains(v));
|
||||
}
|
||||
BlockEntity::Sign(sign) => sign.decode(tag),
|
||||
BlockEntity::Sculk(s) => s.load(tag),
|
||||
BlockEntity::Shelf(shelf) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 3).into_iter().enumerate() {
|
||||
shelf.items[slot] = item;
|
||||
}
|
||||
shelf.align_to_bottom = int("align_items_to_bottom").unwrap_or(0) != 0;
|
||||
}
|
||||
// ChiseledBookShelfBlockEntity.loadAdditional.
|
||||
BlockEntity::ChiseledBookshelf(b) => {
|
||||
for (slot, item) in decode_items(tag.get("Items"), 6).into_iter().enumerate() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Title, world selection and world creation screens (TitleScreen,
|
||||
//! SelectWorldScreen, CreateWorldScreen layouts), drawn as HUD quads.
|
||||
use crate::{
|
||||
dialog_screen::BOX,
|
||||
game_rules_screen::{Exit, RulesScreen},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
@@ -33,6 +34,12 @@ fn edit_top(h: f32) -> f32 {
|
||||
pub(crate) fn confirm_top(h: f32, lines: usize) -> f32 {
|
||||
((h.ceil() - 61.0 - lines as f32 * 9.0) / 2.0).floor()
|
||||
}
|
||||
/// The SafetyScreen's column (WarningScreen: title, 12 px around the message
|
||||
/// box of its lines and 4 px padding, the checkbox, then the buttons, 8 px
|
||||
/// apart): the title's top.
|
||||
fn warning_top(h: f32, lines: usize) -> f32 {
|
||||
((h.ceil() - 102.0 - lines as f32 * 9.0) / 2.0).floor()
|
||||
}
|
||||
|
||||
fn translate(key: &str, fallback: &str, args: &[&dyn std::fmt::Display]) -> String {
|
||||
let args: Vec<Text> = args
|
||||
@@ -119,6 +126,11 @@ pub enum Mode {
|
||||
return_to: Box<Mode>,
|
||||
},
|
||||
Multiplayer(crate::multiplayer::Screen),
|
||||
/// SafetyScreen: the warning before the first Multiplayer click, with its
|
||||
/// "Do not show this screen again" box.
|
||||
MultiplayerWarning {
|
||||
check: bool,
|
||||
},
|
||||
/// CreateFlatWorldScreen over Create New World: the fields that screen
|
||||
/// keeps and the superflat editor on top of them.
|
||||
CreateFlat {
|
||||
@@ -160,6 +172,8 @@ pub struct Title {
|
||||
pub mode: Mode,
|
||||
/// Direct Connection's address, synchronized with `options.txt`'s `lastServer`.
|
||||
pub last_server: String,
|
||||
/// `skipMultiplayerWarning`, synchronized with `options.txt` the same way.
|
||||
pub skip_multiplayer_warning: bool,
|
||||
pub saves: PathBuf,
|
||||
/// The prepared resource root: the flat preset list and block registry.
|
||||
resources: PathBuf,
|
||||
@@ -171,6 +185,8 @@ pub struct Title {
|
||||
scroll: Cell<crate::ui::Scroll>,
|
||||
create_return_to_main: bool,
|
||||
message_lines: Cell<usize>,
|
||||
/// The SafetyScreen's checkbox as last drawn: x, y, width, height.
|
||||
warning_check: Cell<[f32; 4]>,
|
||||
backup: Option<mpsc::Receiver<Result<u64, String>>>,
|
||||
backup_shown: Cell<bool>,
|
||||
/// The backup a `BackupBeforeUpgrade` asked for opens its world when done.
|
||||
@@ -297,6 +313,7 @@ impl Title {
|
||||
Self {
|
||||
mode,
|
||||
last_server: String::new(),
|
||||
skip_multiplayer_warning: false,
|
||||
saves,
|
||||
resources: resources.to_owned(),
|
||||
started: now,
|
||||
@@ -306,6 +323,7 @@ impl Title {
|
||||
scroll: Cell::default(),
|
||||
create_return_to_main: false,
|
||||
message_lines: Cell::new(1),
|
||||
warning_check: Cell::new([0.0; 4]),
|
||||
backup: None,
|
||||
backup_shown: Cell::new(false),
|
||||
backup_then_open: false,
|
||||
@@ -1034,6 +1052,28 @@ impl Title {
|
||||
),
|
||||
]
|
||||
}
|
||||
Mode::MultiplayerWarning { .. } => {
|
||||
let lines = self.message_lines.get();
|
||||
let y = warning_top(h, lines) + 82.0 + lines as f32 * 9.0;
|
||||
vec![
|
||||
b(
|
||||
(w / 2.0).floor() - 154.0,
|
||||
y,
|
||||
150.0,
|
||||
&lang::get_or("gui.proceed", "Proceed"),
|
||||
true,
|
||||
70,
|
||||
),
|
||||
b(
|
||||
(w / 2.0).floor() + 4.0,
|
||||
y,
|
||||
150.0,
|
||||
&lang::get_or("gui.back", "Back"),
|
||||
true,
|
||||
71,
|
||||
),
|
||||
]
|
||||
}
|
||||
Mode::Backup { .. } => Vec::new(),
|
||||
Mode::Multiplayer(screen) => screen
|
||||
.buttons(w, h)
|
||||
@@ -1365,6 +1405,74 @@ impl Title {
|
||||
centered(atlas, batch, line, top + 17.0 + i as f32 * 9.0, u32::MAX);
|
||||
}
|
||||
}
|
||||
Mode::MultiplayerWarning { check } => {
|
||||
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
|
||||
let message = lang::get_or(
|
||||
"multiplayerWarning.message",
|
||||
"Caution: Online play is offered by third-party servers that are not owned, operated, or supervised by Mojang Studios or Microsoft. During online play, you may be exposed to unmoderated chat messages or other types of user-generated content that may not be suitable for everyone.",
|
||||
);
|
||||
// The message sits in a text box `width - 100` wide with 4 px
|
||||
// inside, left aligned (FittingMultiLineTextWidget).
|
||||
let box_w = w - 100.0;
|
||||
let lines = wrapped(atlas, &message, box_w - 8.0);
|
||||
self.message_lines.set(lines.len());
|
||||
let top = warning_top(h, lines.len());
|
||||
let header = Text::translate("multiplayerWarning.header", &[]).bold();
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&header,
|
||||
(w / 2.0).floor() - (atlas.font.width_text(&header) / 2.0).floor(),
|
||||
top,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
let box_x = ((w - box_w) / 2.0).floor();
|
||||
let box_y = top + 29.0;
|
||||
let box_h = lines.len() as f32 * 9.0 + 8.0;
|
||||
crate::ui::nine_slice(
|
||||
batch,
|
||||
atlas,
|
||||
"gui/sprites/widget/text_field",
|
||||
box_x,
|
||||
box_y,
|
||||
box_w,
|
||||
box_h,
|
||||
u32::MAX,
|
||||
);
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
batch.text(
|
||||
atlas,
|
||||
line,
|
||||
box_x + 4.0,
|
||||
box_y + 4.0 + i as f32 * 9.0,
|
||||
u32::MAX,
|
||||
true,
|
||||
);
|
||||
}
|
||||
// Checkbox: the box, its label right of it (as the dialogs draw theirs).
|
||||
let label =
|
||||
lang::get_or("multiplayerWarning.check", "Do not show this screen again");
|
||||
let width = BOX + 4.0 + HudBatch::text_width(atlas, &label).ceil();
|
||||
let x = (w / 2.0).floor() - (width / 2.0).floor();
|
||||
let y = top + 57.0 + lines.len() as f32 * 9.0;
|
||||
self.warning_check.set([x, y, width, BOX]);
|
||||
let hover = (x..x + width).contains(&cursor.0) && (y..y + BOX).contains(&cursor.1);
|
||||
let sprite = match (*check, hover) {
|
||||
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
|
||||
(true, false) => "gui/sprites/widget/checkbox_selected",
|
||||
(false, true) => "gui/sprites/widget/checkbox_highlighted",
|
||||
(false, false) => "gui/sprites/widget/checkbox",
|
||||
};
|
||||
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
|
||||
batch.text(
|
||||
atlas,
|
||||
&label,
|
||||
x + BOX + 4.0,
|
||||
y + 4.0,
|
||||
rgba(224, 224, 224, 255),
|
||||
true,
|
||||
);
|
||||
}
|
||||
Mode::Multiplayer(screen) => screen.draw(
|
||||
atlas,
|
||||
batch,
|
||||
@@ -1471,6 +1579,14 @@ impl Title {
|
||||
}
|
||||
return (Action::None, changed);
|
||||
}
|
||||
// The SafetyScreen's box toggles where it was last drawn.
|
||||
if let Mode::MultiplayerWarning { check } = &mut self.mode {
|
||||
let [bx, by, bw, bh] = self.warning_check.get();
|
||||
if (bx..bx + bw).contains(&x) && (by..by + bh).contains(&y) {
|
||||
*check = !*check;
|
||||
return (Action::None, true);
|
||||
}
|
||||
}
|
||||
let hit = self
|
||||
.buttons(w, h)
|
||||
.into_iter()
|
||||
@@ -1493,9 +1609,26 @@ impl Title {
|
||||
}
|
||||
(Mode::Main, Some(2)) => Action::Quit,
|
||||
(Mode::Main, Some(4)) => {
|
||||
// TitleScreen.init: SafetyScreen first, unless it was turned off.
|
||||
self.mode = if self.skip_multiplayer_warning {
|
||||
Mode::Multiplayer(crate::multiplayer::Screen::list(&self.saves))
|
||||
} else {
|
||||
Mode::MultiplayerWarning { check: false }
|
||||
};
|
||||
Action::None
|
||||
}
|
||||
(Mode::MultiplayerWarning { check }, Some(70)) => {
|
||||
// SafetyScreen Proceed: the box saves the choice.
|
||||
if *check {
|
||||
self.skip_multiplayer_warning = true;
|
||||
}
|
||||
self.mode = Mode::Multiplayer(crate::multiplayer::Screen::list(&self.saves));
|
||||
Action::None
|
||||
}
|
||||
(Mode::MultiplayerWarning { .. }, Some(71)) => {
|
||||
self.mode = Mode::Main;
|
||||
Action::None
|
||||
}
|
||||
(Mode::Multiplayer(_), hit) => match self.multiplayer_click(hit, x, y, (w, h)) {
|
||||
crate::multiplayer::Outcome::Back => {
|
||||
self.mode = Mode::Main;
|
||||
|
||||
@@ -88,6 +88,9 @@ fn direct_connection_remembers_the_address_on_cancel_escape_and_join() {
|
||||
let mut f = Fixture::new();
|
||||
f.title.last_server = "localhost:25566".into();
|
||||
f.click("Multiplayer");
|
||||
// SafetyScreen comes first while its box was never ticked.
|
||||
assert!(matches!(f.title.mode, Mode::MultiplayerWarning { .. }));
|
||||
f.click("Proceed");
|
||||
f.click("Direct Connection");
|
||||
assert_eq!(f.title.focused_field().unwrap().1, "localhost:25566");
|
||||
|
||||
@@ -100,6 +103,7 @@ fn direct_connection_remembers_the_address_on_cancel_escape_and_join() {
|
||||
f.title.escape();
|
||||
f.title.escape();
|
||||
f.click("Multiplayer");
|
||||
f.click("Proceed");
|
||||
f.click("Direct Connection");
|
||||
assert_eq!(f.title.focused_field().unwrap().1, "[::1]:25568");
|
||||
|
||||
@@ -122,6 +126,7 @@ fn direct_connection_remembers_the_address_on_cancel_escape_and_join() {
|
||||
fn f5_reloads_the_server_list() {
|
||||
let mut f = Fixture::new();
|
||||
f.click("Multiplayer");
|
||||
f.click("Proceed");
|
||||
let file = crate::multiplayer::servers_file(&f.title.saves);
|
||||
horizon_protocol::servers::save(
|
||||
&file,
|
||||
|
||||
@@ -694,7 +694,7 @@ impl ChunkGenerator for VanillaTerrain {
|
||||
crate::structure_blocks::block_entities(&chunk, self.loot.as_deref());
|
||||
out.block_entities = block_entities;
|
||||
// The block entities Horizon does not model (decorated pots with their
|
||||
// sherds and loot table, brushable blocks) stay as placed, like a
|
||||
// sherds and loot table) stay as placed, like a
|
||||
// loaded chunk's.
|
||||
let mut unmodelled: Vec<Tag> = chunk
|
||||
.block_entities
|
||||
|
||||
@@ -91,7 +91,7 @@ pub fn cbrt(x: f64) -> f64 {
|
||||
|
||||
/// `Math.atan2`.
|
||||
pub fn atan2(y: f64, x: f64) -> f64 {
|
||||
libm::atan2(y, x)
|
||||
crate::geometry::trig::fdlibm_atan2(y, x)
|
||||
}
|
||||
|
||||
/// `Math.atan`.
|
||||
@@ -614,4 +614,94 @@ mod tests {
|
||||
check1(libm::tan, (0xc00e37e234490564, 0xbfe79ca6eee66726));
|
||||
check1(libm::tan, (0xc075c085c04800b5, 0x3fea18b4a92fcf22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atan2_matches_strictmath() {
|
||||
// (y bits, x bits, StrictMath.atan2 bits) from HotSpot 25: x < 0 and
|
||||
// |y/x| > 2^60, where libm::atan2 rounds one ulp lower,
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x440e8aff5d1e293c, 0xc007e9768e83b37e, 0x3ff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x440c610b44a22b37, 0xbfb07327bcb4c280, 0x3ff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x43eb71600e035874, 0xbfeaff77e5a9ea38, 0x3ff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x440edb2112965d8f, 0xbffaec4345ac33b1, 0x3ff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x3ff0000000000000, 0x8000000000000001, 0x3ff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0xbff0000000000000, 0x8000000000000001, 0xbff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x7fefffffffffffff, 0xbff0000000000000, 0x3ff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0xffefffffffffffff, 0xbff0000000000000, 0xbff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x38fd955bf292fbac, 0xb4f940da66879af2, 0x3ff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0xf8954a6a9b403072, 0xf49292c2e2d50436, 0xbff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x07e155d47eb507f2, 0x83d22629ee8e8471, 0x3ff921fb54442d19),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0xd2471cd599853356, 0xce403e2e72061985, 0xbff921fb54442d19),
|
||||
);
|
||||
// and the other quadrants and special cases, which it already matched
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x440e8aff5d1e293c, 0x4007e9768e83b37e, 0x3ff921fb54442d18),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0xc40e8aff5d1e293c, 0x4007e9768e83b37e, 0xbff921fb54442d18),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x0000000000000000, 0xbff0000000000000, 0x400921fb54442d18),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x8000000000000000, 0xbff0000000000000, 0xc00921fb54442d18),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x3ff0000000000000, 0x3ff0000000000000, 0x3fe921fb54442d18),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x3ff0000000000000, 0xc410000000000000, 0x400921fb54442d18),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x3e7ad7f29abcaf48, 0xc0f86a0000000000, 0x400921fb5444244c),
|
||||
);
|
||||
check2(
|
||||
super::atan2,
|
||||
(0x7ff0000000000000, 0xfff0000000000000, 0x4002d97c7f3321d2),
|
||||
);
|
||||
// NaN payloads may differ, so only NaN-ness is checked.
|
||||
assert!(super::atan2(f64::NAN, 1.0).is_nan());
|
||||
assert!(super::atan2(1.0, f64::NAN).is_nan());
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,11 @@ wgpu = { version = "30.0.1", default-features = false, features = ["vulkan"] }
|
||||
# The version wgpu-hal already uses.
|
||||
ash = { version = "0.38", default-features = false }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# The versions winit already uses, with the color-management protocol (staging).
|
||||
wayland-backend = { version = "0.3.10", default-features = false, features = ["client_system"] }
|
||||
wayland-client = { version = "0.31.10", default-features = false }
|
||||
wayland-protocols = { version = "0.32.8", default-features = false, features = ["client", "staging"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -3,6 +3,9 @@
|
||||
//! without it a compositor may clip or tone-map everything above 1.0 (80 nits)
|
||||
//! of an scRGB swapchain.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod wayland;
|
||||
|
||||
/// Opens a Vulkan device with `VK_EXT_hdr_metadata` too, when the GPU offers
|
||||
/// it, so [`set_metadata`] can tell the compositor the real peak brightness.
|
||||
/// `None` leaves opening to wgpu.
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! What a Wayland compositor says it shows a surface with (`wp_color_management_v1`): the
|
||||
//! luminances of the surface's preferred image description. A compositor that manages
|
||||
//! colour (KWin, Mutter, wlroots) maps HDR content onto the display relative to a
|
||||
//! reference white and tone-maps above what the display reaches, so the game can't
|
||||
//! measure the display through it: it asks instead.
|
||||
//!
|
||||
//! The watch runs on winit's own connection, on an event queue of its own, so winit's
|
||||
//! events are left alone; winit's loop reads the socket, the watch only looks at what
|
||||
//! landed on its queue.
|
||||
use std::ffi::c_void;
|
||||
use wayland_backend::client::{Backend, ObjectId};
|
||||
use wayland_client::{
|
||||
Connection, Dispatch, EventQueue, Proxy, QueueHandle,
|
||||
protocol::{wl_registry, wl_surface::WlSurface},
|
||||
};
|
||||
use wayland_protocols::wp::color_management::v1::client::{
|
||||
wp_color_management_surface_feedback_v1::{self, WpColorManagementSurfaceFeedbackV1},
|
||||
wp_color_manager_v1::{self, WpColorManagerV1},
|
||||
wp_image_description_info_v1::{self, WpImageDescriptionInfoV1},
|
||||
wp_image_description_v1::{self, WpImageDescriptionV1},
|
||||
};
|
||||
|
||||
/// A surface's preferred luminances, in cd/m² (nits).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Luminances {
|
||||
/// The reference white: where SDR white sits.
|
||||
pub reference: f32,
|
||||
/// The darkest and brightest the target display shows (`target_luminance`).
|
||||
pub target_min: f32,
|
||||
pub target_max: f32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
manager: Option<(u32, u32)>,
|
||||
/// The compositor changed the surface's preferred description since the last query.
|
||||
changed: bool,
|
||||
ready: Option<bool>,
|
||||
reference: Option<f32>,
|
||||
target: Option<(f32, f32)>,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
/// What a compositor says about one window, kept up to date: [`Watch::changed`] says
|
||||
/// when it changes (another output, the player recalibrating the desktop).
|
||||
///
|
||||
/// Drop it before the window: its objects live on the window's connection.
|
||||
pub struct Watch {
|
||||
queue: EventQueue<State>,
|
||||
state: State,
|
||||
manager: WpColorManagerV1,
|
||||
feedback: WpColorManagementSurfaceFeedbackV1,
|
||||
}
|
||||
|
||||
impl Watch {
|
||||
/// Starts watching `window`'s surface, with what the compositor says now. None off
|
||||
/// Wayland, or where the compositor has no `wp_color_management_v1`.
|
||||
pub fn new(
|
||||
window: &(impl wgpu::rwh::HasDisplayHandle + wgpu::rwh::HasWindowHandle),
|
||||
) -> Option<(Self, Option<Luminances>)> {
|
||||
use wgpu::rwh::{RawDisplayHandle, RawWindowHandle};
|
||||
let (RawDisplayHandle::Wayland(d), RawWindowHandle::Wayland(w)) = (
|
||||
window.display_handle().ok()?.as_raw(),
|
||||
window.window_handle().ok()?.as_raw(),
|
||||
) else {
|
||||
return None;
|
||||
};
|
||||
// Safety: the window's live display and surface, borrowed for the call.
|
||||
let mut watch = unsafe { Self::on(d.display.as_ptr(), w.surface.as_ptr()) }?;
|
||||
let now = watch.query();
|
||||
Some((watch, now))
|
||||
}
|
||||
|
||||
/// [`Watch::new`] on raw handles: `surface` (a `wl_surface*`) on `display` (a
|
||||
/// `wl_display*`).
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `display` and `surface` must be live libwayland objects of the same connection.
|
||||
unsafe fn on(display: *mut c_void, surface: *mut c_void) -> Option<Self> {
|
||||
// Safety: the caller's live display; a foreign one is never closed from here.
|
||||
let backend = unsafe { Backend::from_foreign_display(display.cast()) };
|
||||
let conn = Connection::from_backend(backend);
|
||||
// Safety: the caller's live wl_surface on this connection.
|
||||
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), surface.cast()) }.ok()?;
|
||||
let surface = WlSurface::from_id(&conn, id).ok()?;
|
||||
let mut queue: EventQueue<State> = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let mut state = State::default();
|
||||
let registry = conn.display().get_registry(&qh, ());
|
||||
queue.roundtrip(&mut state).ok()?;
|
||||
let (name, version) = state.manager?;
|
||||
let manager: WpColorManagerV1 = registry.bind(name, version.min(1), &qh, ());
|
||||
let feedback = manager.get_surface_feedback(&surface, &qh, ());
|
||||
Some(Self {
|
||||
queue,
|
||||
state,
|
||||
manager,
|
||||
feedback,
|
||||
})
|
||||
}
|
||||
|
||||
/// What the compositor says now, if it changed since the last call. Cheap: it only
|
||||
/// looks at events the window's own loop already read.
|
||||
pub fn changed(&mut self) -> Option<Option<Luminances>> {
|
||||
self.queue.dispatch_pending(&mut self.state).ok()?;
|
||||
if !std::mem::take(&mut self.state.changed) {
|
||||
return None;
|
||||
}
|
||||
Some(self.query())
|
||||
}
|
||||
|
||||
/// The surface's preferred description now.
|
||||
fn query(&mut self) -> Option<Luminances> {
|
||||
let qh = self.queue.handle();
|
||||
let state = &mut self.state;
|
||||
(state.ready, state.reference, state.target, state.done) = (None, None, None, false);
|
||||
let description = self.feedback.get_preferred(&qh, ());
|
||||
let found = (|| {
|
||||
while state.ready.is_none() {
|
||||
self.queue.blocking_dispatch(state).ok()?;
|
||||
}
|
||||
if state.ready != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let _info = description.get_information(&qh, ());
|
||||
while !state.done {
|
||||
self.queue.blocking_dispatch(state).ok()?;
|
||||
}
|
||||
let (target_min, target_max) = state.target?;
|
||||
Some(Luminances {
|
||||
reference: state.reference?,
|
||||
target_min,
|
||||
target_max,
|
||||
})
|
||||
})();
|
||||
description.destroy();
|
||||
found
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Watch {
|
||||
fn drop(&mut self) {
|
||||
self.feedback.destroy();
|
||||
self.manager.destroy();
|
||||
let _ = self.queue.flush();
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
&& interface == WpColorManagerV1::interface().name
|
||||
{
|
||||
state.manager = Some((name, version));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Dispatch<WpColorManagerV1, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &WpColorManagerV1,
|
||||
_: wp_color_manager_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
impl Dispatch<WpColorManagementSurfaceFeedbackV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WpColorManagementSurfaceFeedbackV1,
|
||||
event: wp_color_management_surface_feedback_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wp_color_management_surface_feedback_v1::Event::PreferredChanged { .. } = event {
|
||||
state.changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Dispatch<WpImageDescriptionV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WpImageDescriptionV1,
|
||||
event: wp_image_description_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wp_image_description_v1::Event::Ready { .. } => state.ready = Some(true),
|
||||
wp_image_description_v1::Event::Failed { .. } => state.ready = Some(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Dispatch<WpImageDescriptionInfoV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &WpImageDescriptionInfoV1,
|
||||
event: wp_image_description_info_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
wp_image_description_info_v1::Event::Luminances { reference_lum, .. } => {
|
||||
state.reference = Some(reference_lum as f32);
|
||||
}
|
||||
wp_image_description_info_v1::Event::TargetLuminance { min_lum, max_lum } => {
|
||||
state.target = Some((min_lum as f32 / 10_000.0, max_lum as f32));
|
||||
}
|
||||
wp_image_description_info_v1::Event::Done => state.done = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,8 @@ pub struct ItemInfo {
|
||||
pub weapon_damage: Option<u32>,
|
||||
/// `use_effects.speed_multiplier` (UseEffects.DEFAULT: 0.2).
|
||||
pub use_speed: f32,
|
||||
/// `use_effects.interact_vibrations` (default true).
|
||||
pub interact_vibrations: bool,
|
||||
/// `glider` (elytra).
|
||||
pub glider: bool,
|
||||
/// `equippable.swappable` and `equip_sound`.
|
||||
@@ -663,6 +665,9 @@ impl Items {
|
||||
use_speed: c["minecraft:use_effects"]["speed_multiplier"]
|
||||
.as_f64()
|
||||
.unwrap_or(0.2) as f32,
|
||||
interact_vibrations: c["minecraft:use_effects"]["interact_vibrations"]
|
||||
.as_bool()
|
||||
.unwrap_or(true),
|
||||
blocks_attacks: BlocksAttacks::parse(&c["minecraft:blocks_attacks"]),
|
||||
disable_blocking_seconds: c["minecraft:weapon"]["disable_blocking_for_seconds"]
|
||||
.as_f64()
|
||||
|
||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user