mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2436893012 | ||
|
|
9fd2e38592 | ||
|
|
99bc69d862 | ||
|
|
476dabcb08 | ||
|
|
911e23fb55 | ||
|
|
ffaaa63836 | ||
|
|
ee60188af7 | ||
|
|
f518ef4c07 | ||
|
|
2719b22258 | ||
|
|
3da374ef18 |
No files matched your search
+18
-32
@@ -1,5 +1,5 @@
|
||||
name: checks
|
||||
# verify: every pull request, every merge queue entry and every push to main (runners: see the job). It is the
|
||||
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
|
||||
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
|
||||
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
|
||||
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
|
||||
@@ -7,15 +7,11 @@ name: checks
|
||||
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
|
||||
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
|
||||
# never start this workflow. See docs/release-flow.md.
|
||||
# merge_group: the merge queue's commit (main's tip, the pull requests ahead in the queue and this one) runs verify
|
||||
# before it lands, on GitHub's runners; parity and launcher wait for main's push (docs/release-flow.md#merge-queue).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -23,8 +19,7 @@ concurrency:
|
||||
# A new push to a pull request replaces its running checks. On main, the running check finishes and only the
|
||||
# newest push waits behind it: GitHub cancels a waiting run when a newer one joins the group. A burst of merges
|
||||
# queued ~100 main runs on 2026-11-09; the release follows the newest finished main run (docs/release-flow.md).
|
||||
# Each merge queue entry has a group of its own (its temporary branch), and nothing cancels it: the queue does.
|
||||
group: checks-${{ github.event.pull_request.number || github.event.merge_group.head_ref || (github.event_name == 'push' && 'main') || github.sha }}
|
||||
group: checks-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
|
||||
@@ -39,14 +34,11 @@ jobs:
|
||||
# forks' pull requests and main's pushes (which also save the Rust cache GitHub's pull request runs read), so both
|
||||
# pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to ours, `light` (the
|
||||
# desktop paused) or `off` every job to GitHub's. A fork's pull request sees no repository variable, so no switch
|
||||
# could move it off a machine that is off: it always runs on GitHub's. So does a merge queue's run, whatever
|
||||
# SELF_HOSTED_CI says: its commit can hold a fork's code, which job-started.sh refuses on ours. On ours the build
|
||||
# folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are
|
||||
# GitHub's only.
|
||||
# could move it off a machine that is off: it always runs on GitHub's. On ours the build folder persists
|
||||
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
|
||||
verify:
|
||||
runs-on: >-
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
|
||||
(vars.SELF_HOSTED_CI == 'all' ||
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
|
||||
@@ -60,24 +52,22 @@ jobs:
|
||||
if: runner.environment == 'github-hosted'
|
||||
with:
|
||||
python-version: '3.12'
|
||||
# A merge queue's run reads its commit against main's tip (merge_group's base and head).
|
||||
- name: Does this pull request need Rust checks?
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
|
||||
if: github.event_name == 'pull_request'
|
||||
id: rust
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||
# A pull request (or a merge queue entry) adds only its own commits to main, so it reads those; main's pushes
|
||||
# read every ref's history.
|
||||
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
|
||||
- name: Keep game artifacts out of Git
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
python tools/check_public_tree.py
|
||||
if [ "$EVENT" = pull_request ] || [ "$EVENT" = merge_group ]; then
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
|
||||
else
|
||||
python tools/check_public_tree.py --history
|
||||
@@ -94,22 +84,21 @@ jobs:
|
||||
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
|
||||
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
|
||||
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
|
||||
# changes since then would count as the pull request's (#13874). A merge queue's entry compares with main's tip
|
||||
# (merge_group.base_sha), whatever the queue's merge method makes of HEAD^1.
|
||||
# changes since then would count as the pull request's (#13874).
|
||||
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
|
||||
- name: Crates follow the layers
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
|
||||
- name: Built-in addresses stay in their home
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
|
||||
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
|
||||
- name: Big files don't grow
|
||||
env:
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
|
||||
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
|
||||
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
|
||||
- name: Test offline research tooling
|
||||
run: python -m unittest discover -s tests -v
|
||||
@@ -150,13 +139,10 @@ jobs:
|
||||
cargo +stable test --locked --workspace --doc
|
||||
# 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). Not in the merge queue: no check of
|
||||
# its is required, so the queue wouldn't wait for it, and main's push runs it anyway.
|
||||
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
|
||||
launcher:
|
||||
if: github.event_name != 'merge_group'
|
||||
runs-on: >-
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
|
||||
(vars.SELF_HOSTED_CI == 'all' ||
|
||||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
|
||||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
|
||||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
|
||||
|
||||
Generated
+1
-2
@@ -1486,7 +1486,6 @@ dependencies = [
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
"horizon-system",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1850,8 +1849,8 @@ dependencies = [
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"horizon-system",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
|
||||
//! come from the program that rendered (the game knows its adapter, the launcher its window).
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use horizon_telemetry::system::command_output as output;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -69,12 +68,12 @@ impl Hardware {
|
||||
os: std::env::consts::OS.to_owned(),
|
||||
os_version: os_version(),
|
||||
arch: std::env::consts::ARCH.to_owned(),
|
||||
cpu: horizon_telemetry::system::cpu(),
|
||||
cpu: cpu(),
|
||||
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
cpu_cores: probes.cores,
|
||||
cpu_base_mhz: probes.base,
|
||||
cpu_boost_mhz: probes.boost,
|
||||
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
|
||||
ram_mb: ram_bytes().map(|b| b >> 20),
|
||||
storage: None,
|
||||
form: probes.form,
|
||||
gpu: None,
|
||||
@@ -110,16 +109,84 @@ fn probes() -> Probes {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one-minute load average: above about the number of threads the machine was busy.
|
||||
pub fn load_average() -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "{ 1.23 1.10 1.00 }"
|
||||
output("sysctl", &["-n", "vm.loadavg"])?
|
||||
.trim_start_matches('{')
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
} else {
|
||||
std::fs::read_to_string("/proc/loadavg")
|
||||
.ok()?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
|
||||
pub fn power_source() -> Option<&'static str> {
|
||||
horizon_telemetry::system::power()
|
||||
}
|
||||
|
||||
/// Resident memory of a process, in bytes (macOS, Linux).
|
||||
pub fn rss_bytes(pid: u32) -> Option<u64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024);
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
|
||||
line.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
|
||||
pub fn cpu_seconds(pid: u32) -> Option<f64> {
|
||||
if cfg!(target_os = "macos") {
|
||||
// "[[dd-]hh:]mm:ss.cc"
|
||||
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
|
||||
let (rest, days) = match time.split_once('-') {
|
||||
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
|
||||
None => (time, 0.0),
|
||||
};
|
||||
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
|
||||
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
|
||||
})?;
|
||||
return Some(days * 86_400.0 + seconds);
|
||||
}
|
||||
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
|
||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
|
||||
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
|
||||
Some(ticks / 100.0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_version() -> Option<String> {
|
||||
output("sw_vers", &["-productVersion"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn cpu() -> Option<String> {
|
||||
output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_version() -> Option<String> {
|
||||
let release = std::fs::read_to_string("/etc/os-release").ok()?;
|
||||
@@ -132,6 +199,22 @@ fn os_version() -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||
let kb = key_value(&info, "MemTotal", ':')?
|
||||
.trim_end_matches("kB")
|
||||
.trim();
|
||||
kb.parse::<u64>().ok().map(|kb| kb * 1024)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
text.lines().find_map(|line| {
|
||||
@@ -145,11 +228,39 @@ fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
let out = output(
|
||||
"powershell",
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
|
||||
],
|
||||
)?;
|
||||
out.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn os_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn cpu() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
fn ram_bytes() -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -163,6 +274,9 @@ mod tests {
|
||||
assert!(hw.cpu.is_some(), "{hw:?}");
|
||||
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
|
||||
assert!(hw.os_version.is_some(), "{hw:?}");
|
||||
assert!(load_average().is_some());
|
||||
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
mod hardware;
|
||||
|
||||
pub use hardware::{power_source, Display, Gpu, Hardware};
|
||||
/// The process probes, from the platform (`horizon-system`).
|
||||
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
|
||||
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
|
||||
|
||||
pub const FORMAT: &str = "horizon-benchmark-report";
|
||||
pub const VERSION: u32 = 1;
|
||||
|
||||
@@ -36,7 +36,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var atlas: texture_2d<f32>;
|
||||
@@ -292,7 +292,7 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
|
||||
var mark = hdr_entity_mark(in);
|
||||
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
|
||||
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
|
||||
@@ -451,7 +451,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = celestial_color(in);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
return frame_stored(color);
|
||||
}
|
||||
@@ -472,7 +472,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
|
||||
// or it would come out darker than the sky around it.
|
||||
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
// Premultiplied after the decode: the light the texel adds, at its coverage.
|
||||
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
|
||||
|
||||
@@ -428,10 +428,10 @@ struct Globals {
|
||||
/// full (`hdr::Hdr::windows`); zero without HDR.
|
||||
hdr_windows: [[f32; 4]; 2],
|
||||
hdr_light: [f32; 4],
|
||||
/// The scene's peak, HDR lighting's gain, world/look and debug; then the tiers' gains
|
||||
/// Headroom, effective scene gain, world/look and debug, as HDR output's pass has them
|
||||
/// (`Hdr::draw_params`).
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
}
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
@@ -8138,14 +8138,14 @@ impl Renderer {
|
||||
self.shader_pack_kind().is_some(),
|
||||
!frame.title,
|
||||
);
|
||||
let (hdr_settings, hdr_gains) = self
|
||||
let (hdr_settings, hdr_shares) = self
|
||||
.hdr
|
||||
.as_ref()
|
||||
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
|
||||
self.world_post.baked_light(
|
||||
hdr_gain,
|
||||
hdr_settings,
|
||||
hdr_gains,
|
||||
hdr_shares,
|
||||
self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
|
||||
);
|
||||
let jitter = self.world_post.jitter(
|
||||
@@ -8305,7 +8305,7 @@ impl Renderer {
|
||||
f32::from(u8::from(hdr_gain)),
|
||||
],
|
||||
hdr_settings,
|
||||
hdr_gains,
|
||||
hdr_shares,
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
|
||||
@@ -33,7 +33,7 @@ struct Uniform {
|
||||
mode: u32,
|
||||
pad: [u32; 2],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -407,7 +407,7 @@ impl CustomSky {
|
||||
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
|
||||
let mark =
|
||||
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
|
||||
if globals.hdr_settings[2] > 0.5 {
|
||||
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
|
||||
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
|
||||
} else {
|
||||
1.0
|
||||
@@ -428,7 +428,7 @@ impl CustomSky {
|
||||
mode: mode as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
let offset = STRIDE * self.frame.len() as u64;
|
||||
@@ -702,7 +702,7 @@ mod tests {
|
||||
mode: mode_index as u32,
|
||||
pad: [0; 2],
|
||||
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
|
||||
hdr_gains: [1.6, 2.4, 2.8, 4.0],
|
||||
hdr_shares: [0.2, 0.5, 0.6, 1.0],
|
||||
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
|
||||
};
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
|
||||
@@ -10,7 +10,7 @@ struct Layer {
|
||||
mode: u32,
|
||||
pad: vec2<u32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> layer: Layer;
|
||||
@@ -54,7 +54,7 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
var color = t.rgb;
|
||||
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
|
||||
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains);
|
||||
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
|
||||
}
|
||||
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
|
||||
switch layer.mode {
|
||||
@@ -84,5 +84,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
|
||||
|
||||
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
|
||||
let color = custom_sky_color(in);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
|
||||
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
|
||||
}
|
||||
@@ -5,10 +5,6 @@
|
||||
//! or scRGB swapchain at the player's paper white, expanding the world's
|
||||
//! highlights towards their peak brightness. The world is copied aside before
|
||||
//! the hand and HUD so only it is expanded.
|
||||
//!
|
||||
//! On the float frame Horizon's world light is the same on every display (`TIERS`,
|
||||
//! `SUNLIT_GAIN`, up to `SCENE_PEAK`), and the tone curve alone fits it to the display's
|
||||
//! peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
|
||||
/// The swapchain HDR output presents to.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
@@ -103,53 +99,38 @@ struct Layered {
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: its brightest pixels are
|
||||
/// multiplied by `gain`, in multiples of paper white, lifted from brightness `start` (none) to
|
||||
/// `full` (all of it), the texel's own brightness before lighting.
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
|
||||
/// texel's own brightness before lighting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub gain: f32,
|
||||
pub share: f32,
|
||||
pub start: f32,
|
||||
pub full: f32,
|
||||
}
|
||||
/// Low, incandescent, bright and very bright: faint glowers such as magma, flames and lava,
|
||||
/// glowstone and campfires, the sun.
|
||||
/// Low, incandescent, bright and very bright.
|
||||
pub const TIERS: [Tier; 4] = [
|
||||
Tier {
|
||||
gain: 1.6,
|
||||
share: 0.2,
|
||||
start: 0.08,
|
||||
full: 0.09,
|
||||
},
|
||||
Tier {
|
||||
gain: 2.4,
|
||||
share: 0.5,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
gain: 2.8,
|
||||
share: 0.6,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
Tier {
|
||||
gain: 4.0,
|
||||
share: 1.0,
|
||||
start: 0.14,
|
||||
full: 0.38,
|
||||
},
|
||||
];
|
||||
/// The scene's peak, in multiples of paper white: just above the brightest light the world
|
||||
/// writes (the sun's disc, white at the very bright tier's 4), and what the tone curve maps
|
||||
/// onto the display's peak; light past it (where the sky adds onto the sun) is held at it. The
|
||||
/// world is mastered for a 1000-nit display at ITU-R BT.2408's 203-nit reference white, as
|
||||
/// HDR10 content usually is: there it shows as written, a dimmer display eases its highlights
|
||||
/// into its peak (BT.2390's EETF), and a brighter one shows it as at 1000.
|
||||
pub const SCENE_PEAK: f32 = 1000.0 / 203.0;
|
||||
/// HDR lighting: what the strongest light multiplies a surface by (`hdr_light.wgsl`).
|
||||
pub const SUNLIT_GAIN: f32 = 3.0;
|
||||
/// The compatibility frame's tiers (the 16-bit or 8-bit frame, until step 5 removes it): each
|
||||
/// one's share of the display's headroom, as before one peak, so the look HDR has by default
|
||||
/// changes once, when the float frame replaces it.
|
||||
const COMPAT_SHARES: [f32; 4] = [0.2, 0.5, 0.6, 1.0];
|
||||
|
||||
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
|
||||
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
|
||||
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
|
||||
@@ -350,7 +331,7 @@ impl Hdr {
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 80,
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
@@ -963,19 +944,16 @@ impl Hdr {
|
||||
})
|
||||
}
|
||||
|
||||
/// What the world's writers read (`hdr_written`): the scene's peak, HDR lighting's gain,
|
||||
/// world/look and debug, then the tiers' gains. No part of the display reaches them.
|
||||
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
|
||||
let p = self.params(world, false);
|
||||
let sunlit = if self.lighting { SUNLIT_GAIN } else { 1.0 };
|
||||
([SCENE_PEAK, sunlit, p[2], p[3]], self.tiers.map(|t| t.gain))
|
||||
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// hdr.wgsl's `Params`.
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
|
||||
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
|
||||
let paper = self.paper_white.max(1.0);
|
||||
let c = self.calibration;
|
||||
let mut params = [0.0; 20];
|
||||
let mut params = [0.0; 16];
|
||||
params[..5].copy_from_slice(&[
|
||||
paper / 80.0,
|
||||
(self.peak / paper).max(1.0),
|
||||
@@ -990,17 +968,12 @@ impl Hdr {
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
params[8..12].copy_from_slice(&COMPAT_SHARES);
|
||||
for (i, t) in self.tiers.iter().enumerate() {
|
||||
params[8 + i] = t.share;
|
||||
}
|
||||
params[12] = c.map_or(0.0, |c| c.step as f32);
|
||||
params[13] = c.map_or(0.0, |c| c.nits);
|
||||
params[14] = c.map_or(0.0, |c| c.band);
|
||||
// Only Horizon's lifted world is known to end at the scene's peak; elsewhere (HDR
|
||||
// Lighting off, a pack's own light, the title) the curve's source follows the display.
|
||||
params[16] = if world && self.lighting {
|
||||
SCENE_PEAK
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
params
|
||||
}
|
||||
|
||||
@@ -1077,9 +1050,8 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// The compatibility frame's HDR lighting (`fs_hdr`): what the strongest light multiplies a
|
||||
/// surface by, 4, which looked right across biomes; less only where there is little headroom
|
||||
/// to ease into. The float frame's is `SUNLIT_GAIN`.
|
||||
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
|
||||
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
|
||||
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
|
||||
}
|
||||
@@ -1097,8 +1069,7 @@ mod tests {
|
||||
return;
|
||||
};
|
||||
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries. The
|
||||
// writers take the gain that a share gives.
|
||||
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ r#"
|
||||
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
|
||||
@@ -1117,20 +1088,19 @@ var<private> params: ReferenceParams;
|
||||
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
|
||||
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
|
||||
let expected = reference_lit(color, mark, shows, eased);
|
||||
let gains = 1.0 + (params.peak - 1.0) * params.share;
|
||||
let actual = hdr_mark_light(color, mark, shows, eased,
|
||||
vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
|
||||
vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
|
||||
// Gain stays above the tone knee until the separate tone curve runs.
|
||||
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), gains);
|
||||
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
|
||||
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
|
||||
let boundary = (f32(i % 255u) + 0.5) / 255.0;
|
||||
for (var step = -1; step <= 1; step++) {
|
||||
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
|
||||
let quantized = round(saturate(stored) * 255.0) / 255.0;
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), gains);
|
||||
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
|
||||
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
|
||||
}
|
||||
}
|
||||
@@ -1349,12 +1319,10 @@ var<private> params: ReferenceParams;
|
||||
|
||||
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
|
||||
// Float targets in these tests only receive the resulting colour and coverage.
|
||||
// `lift`: the reference's peak, HDR lighting gain, world and debug, then the tiers' shares
|
||||
// of the headroom up to that peak (`writers_lift`, `display_lift`).
|
||||
pub(super) fn legacy_light(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
lift: [[f32; 4]; 2],
|
||||
hdr: &Hdr,
|
||||
source: &wgpu::Texture,
|
||||
target: &wgpu::Texture,
|
||||
) {
|
||||
@@ -1377,7 +1345,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let c = textureLoad(source, vec2(0), 0);
|
||||
let mark = round(saturate(c.a) * 255.0) / 255.0;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5, false).light;
|
||||
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
|
||||
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
"#)
|
||||
@@ -1405,9 +1373,10 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: bytemuck::cast_slice(&lift),
|
||||
contents: bytemuck::cast_slice(&[settings, shares]),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let view = source.create_view(&Default::default());
|
||||
@@ -1435,19 +1404,6 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
);
|
||||
}
|
||||
|
||||
/// The float frame's writers' light as `legacy_light` takes it: each tier's gain as a share
|
||||
/// of the headroom up to the scene's peak.
|
||||
pub(super) fn writers_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
[settings, gains.map(|g| (g - 1.0) / (settings[0] - 1.0))]
|
||||
}
|
||||
|
||||
/// The compatibility frame's display pass's light (`fs_hdr`) as `legacy_light` takes it.
|
||||
fn display_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
|
||||
let p = hdr.params(true, false);
|
||||
[[p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
@@ -1460,9 +1416,8 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
|
||||
return;
|
||||
}
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD:
|
||||
// the frozen oracle turns the marks into the light that display gives them, and the
|
||||
// layered display, whose curve is the identity at 1000 nits, must show it the same.
|
||||
// Compare the compatibility display lift with direct colour under the layered HUD.
|
||||
// The frozen oracle supplies the writer's light from a non-float attachment.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr = Hdr::with_debug(
|
||||
&device,
|
||||
@@ -1486,14 +1441,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
if world[3] == 1.0 {
|
||||
fill(&mut encoder, &frame, world);
|
||||
} else {
|
||||
// The light the compatibility frame shows: its own lift, not the writers'.
|
||||
legacy_light(
|
||||
&device,
|
||||
&mut encoder,
|
||||
display_lift(&legacy),
|
||||
&marked,
|
||||
&frame,
|
||||
);
|
||||
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
|
||||
}
|
||||
hdr.begin(&queue, true);
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
@@ -1532,206 +1480,78 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
assert!(above[0] > white[0] && above[1] > white[1]);
|
||||
}
|
||||
|
||||
/// The world's writers read nothing of the display: the same gains and the same scene peak
|
||||
/// at any paper white and peak (docs/design/hdr-linear-light.md, step 6a).
|
||||
#[test]
|
||||
fn the_writers_light_is_the_same_on_every_display() {
|
||||
fn the_tone_curve_eases_light_into_the_peak() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let written = hdr.draw_params(true);
|
||||
assert_eq!(
|
||||
written,
|
||||
(
|
||||
[super::SCENE_PEAK, super::SUNLIT_GAIN, 1.0, 0.0],
|
||||
[1.6, 2.4, 2.8, 4.0]
|
||||
)
|
||||
);
|
||||
for (paper, peak) in [
|
||||
(203.0, 300.0),
|
||||
(203.0, 600.0),
|
||||
(203.0, 1500.0),
|
||||
(400.0, 600.0),
|
||||
(480.0, 200.0),
|
||||
(80.0, 10000.0),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
assert_eq!(hdr.draw_params(true), written, "{paper}/{peak}");
|
||||
assert_eq!(
|
||||
hdr.params(true, false)[16],
|
||||
super::SCENE_PEAK,
|
||||
"{paper}/{peak}"
|
||||
);
|
||||
}
|
||||
// The title, and frames Horizon doesn't light (HDR Lighting off, a pack drawing):
|
||||
// nothing says where their light ends, so the curve's source follows the display.
|
||||
assert_eq!(hdr.params(false, false)[16], 0.0);
|
||||
hdr.lighting = false;
|
||||
assert_eq!(hdr.draw_params(true).0[1], 1.0);
|
||||
assert_eq!(hdr.params(true, false)[16], 0.0);
|
||||
}
|
||||
|
||||
/// The tone curve maps the scene's peak S onto the display's peak T with ITU-R BT.2390's
|
||||
/// EETF, keeping hue, after holding light above S at S. Checkpoints are worked out apart
|
||||
/// from the shader, in f64 from SMPTE ST 2084 and BT.2390.
|
||||
#[test]
|
||||
fn the_tone_curve_maps_the_scene_peak_onto_the_display() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let scene_peak = super::SCENE_PEAK;
|
||||
// The frame holding `color` (in gamma, as it stores light), after the curve if `curve`.
|
||||
let through = |hdr: &mut Hdr, color: [f64; 4], curve: bool| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, color);
|
||||
hdr.begin(&queue, true);
|
||||
if curve {
|
||||
hdr.tonemap(&mut encoder, None);
|
||||
}
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
// `exact`, or within one half-float step: S's own gamma rounds up in half float, to
|
||||
// light just above S that the curve holds at S, and whether that comes back to the same
|
||||
// half float depends on the GPU's rounding (one step lower on an Apple M4 Pro).
|
||||
let unchanged = |hdr: &mut Hdr, color: [f64; 4], exact: bool| {
|
||||
let (got, stored) = (through(hdr, color, true), through(hdr, color, false));
|
||||
(0..4).all(|c| {
|
||||
got[c] == stored[c] || !exact && (got[c] - stored[c]).abs() <= stored[c] / 1024.0
|
||||
})
|
||||
};
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let headroom = 1000.0 / 203.0;
|
||||
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
|
||||
let after =
|
||||
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, color);
|
||||
hdr.begin(&queue, true);
|
||||
passes(hdr, &mut encoder);
|
||||
texel(&device, &queue, encoder, &frame)
|
||||
};
|
||||
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
|
||||
// Linear light in and out, in units of paper white.
|
||||
let mapped = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
|
||||
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
|
||||
let [r, g, b, a] = through(hdr, [r, g, b, 1.0], true);
|
||||
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
|
||||
assert_eq!(a, 1.0);
|
||||
[r, g, b].map(|c| c.powf(2.2))
|
||||
};
|
||||
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
|
||||
// A display at or above the mastering one (T >= S): every stored value up to S comes
|
||||
// out exactly as it went in, grey or coloured, and light above S is held at S with its
|
||||
// hue, so 1500 and 10 000 nits show what 1000 does; so do the settings' extremes (Paper
|
||||
// White 10 to 480 nits, Peak up to 10 000) wherever T reaches S.
|
||||
for (paper, peak) in [
|
||||
(203.0, 1000.0),
|
||||
(203.0, 1500.0),
|
||||
(203.0, 10000.0),
|
||||
(480.0, 10000.0),
|
||||
(10.0, 200.0),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for i in 0..=64 {
|
||||
let g = f64::from((scene_peak * i as f32 / 64.0).powf(1.0 / 2.2));
|
||||
let exact = i < 64;
|
||||
assert!(unchanged(&mut hdr, [g, g, g, 1.0], exact), "{peak}: {i}");
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.6, g * 0.1, 1.0], exact),
|
||||
"{peak}: {i}"
|
||||
);
|
||||
}
|
||||
for light in [8.0f32, 50.0] {
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
near(r, scene_peak) && near(g * 4.0, r) && b == 0.0,
|
||||
"{peak}: {light}: {r} {g} {b}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Dimmer displays: unchanged up to the knee, then the EETF into T, T at S and past it.
|
||||
// At 300 nits the knee falls just below white (161 nits): white shows at 198 nits
|
||||
// where the gates' curve, whose source followed the display, kept it at 203; the
|
||||
// highlights above it keep more of their steps.
|
||||
for (paper, peak, knee, checkpoints) in [
|
||||
(
|
||||
203.0,
|
||||
600.0,
|
||||
2.0,
|
||||
&[
|
||||
(2.5, 2.47516),
|
||||
(3.0, 2.75699),
|
||||
(4.0, 2.9404),
|
||||
(scene_peak, 600.0 / 203.0),
|
||||
(8.0, 600.0 / 203.0),
|
||||
][..],
|
||||
),
|
||||
(
|
||||
203.0,
|
||||
300.0,
|
||||
0.75,
|
||||
&[
|
||||
(0.9, 0.89289),
|
||||
(1.0, 0.97365),
|
||||
(1.2, 1.10406),
|
||||
(1.4, 1.20134),
|
||||
(2.0, 1.36833),
|
||||
(4.0, 1.47641),
|
||||
(8.0, 300.0 / 203.0),
|
||||
],
|
||||
),
|
||||
(
|
||||
400.0,
|
||||
600.0,
|
||||
0.8,
|
||||
&[
|
||||
(1.0, 0.97973),
|
||||
(1.2, 1.11501),
|
||||
(1.5, 1.25607),
|
||||
(2.0, 1.38818),
|
||||
(scene_peak, 1.5),
|
||||
],
|
||||
),
|
||||
// No headroom (a peak under paper white): the scene is compressed into white.
|
||||
(
|
||||
203.0,
|
||||
150.0,
|
||||
0.4,
|
||||
&[
|
||||
(1.0, 0.78346),
|
||||
(1.5, 0.90244),
|
||||
(2.0, 0.95589),
|
||||
(4.0, 0.99944),
|
||||
(scene_peak, 1.0),
|
||||
],
|
||||
),
|
||||
] {
|
||||
(hdr.paper_white, hdr.peak) = (paper, peak);
|
||||
for light in [0.0f32, 0.3, knee] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
assert!(
|
||||
unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true),
|
||||
"{paper}/{peak}: {light}"
|
||||
);
|
||||
}
|
||||
for &(light, want) in checkpoints {
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{paper}/{peak}: {light}: {r}, not {want}");
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others; a
|
||||
// half float's step in gamma is 0.1% of the light near white.
|
||||
let headroom = (peak / paper).max(1.0);
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 0.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(
|
||||
r >= last * 0.998 && r <= headroom * 1.002,
|
||||
"{peak}: {light}: {r}"
|
||||
);
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{peak}: {light}: {r} {g} {b}");
|
||||
last = r;
|
||||
}
|
||||
}
|
||||
// Light Horizon doesn't lift (HDR Lighting off, a 0.2 pack's own) has no known peak:
|
||||
// the curve's source follows the display, the knee at 60% of its peak and at least
|
||||
// white, as before one peak. At 300 nits the source's peak is 645 nits.
|
||||
hdr.lighting = false;
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 300.0);
|
||||
let headroom = 300.0 / 203.0;
|
||||
for light in [0.0f32, 0.5, 0.9, 1.0] {
|
||||
// Up to the knee (60% of the peak) the light stays exactly as stored.
|
||||
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
assert!(unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true), "{light}");
|
||||
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
|
||||
assert_eq!(
|
||||
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
|
||||
stored,
|
||||
"{light}"
|
||||
);
|
||||
}
|
||||
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
|
||||
// nits (13.7 times paper white), worked out apart from the shader.
|
||||
for (light, want) in [
|
||||
(3.5, 3.43791),
|
||||
(4.0, 3.78351),
|
||||
(headroom, 4.23556),
|
||||
(8.0, 4.82043),
|
||||
(12.0, 4.92462),
|
||||
(50.0, headroom),
|
||||
] {
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
}
|
||||
// It rises, stays under the peak, and the brightest channel scales the others: the
|
||||
// light keeps its hue.
|
||||
let mut last = 0.0;
|
||||
for i in 0..40 {
|
||||
let light = 2.5 * 1.08f32.powi(i);
|
||||
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
|
||||
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
|
||||
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
|
||||
last = r;
|
||||
}
|
||||
// A display with little headroom, 300 nits at a paper white of 203: the knee sits at
|
||||
// white, so all that SDR shows stays as it is, and the source's peak is 645 nits.
|
||||
hdr.peak = 300.0;
|
||||
let headroom = 300.0 / 203.0;
|
||||
for light in [0.5f32, 0.9, 1.0] {
|
||||
let g = f64::from(light.powf(1.0 / 2.2));
|
||||
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
|
||||
assert_eq!(
|
||||
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
|
||||
stored,
|
||||
"{light}"
|
||||
);
|
||||
}
|
||||
for (light, want) in [
|
||||
(1.2, 1.16839),
|
||||
@@ -1740,12 +1560,12 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(2.0, 1.44024),
|
||||
(4.0, headroom),
|
||||
] {
|
||||
let [r, ..] = mapped(&mut hdr, [light; 3]);
|
||||
let [r, ..] = eased(&mut hdr, [light; 3]);
|
||||
assert!(near(r, want), "{light}: {r}, not {want}");
|
||||
}
|
||||
// With no headroom there, a clip at white, channel by channel.
|
||||
// No headroom (a peak below paper white): a clip at white, channel by channel.
|
||||
hdr.peak = 150.0;
|
||||
let [r, g, b] = mapped(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
|
||||
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
|
||||
}
|
||||
|
||||
@@ -1762,7 +1582,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
|
||||
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
|
||||
hdr.masks.parts = 0.4;
|
||||
hdr.tiers[1].gain = 3.0;
|
||||
hdr.tiers[1].share = 0.7;
|
||||
hdr.debug = true;
|
||||
hdr.calibration = Some(super::Calibration {
|
||||
step: 2,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{gpu, legacy_light, texel, writers_lift},
|
||||
tests::{gpu, legacy_light, texel},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
use wgpu::util::DeviceExt;
|
||||
@@ -648,7 +648,7 @@ impl<'a> Draw<'a> {
|
||||
}
|
||||
if !written && !self.raw {
|
||||
let light = hdr.frame(self.device, [1, 1]);
|
||||
legacy_light(self.device, &mut encoder, writers_lift(hdr), &frame, &light);
|
||||
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
|
||||
return texel(self.device, self.queue, encoder, &light);
|
||||
}
|
||||
texel(self.device, self.queue, encoder, &frame)
|
||||
@@ -662,7 +662,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let mut cases = 0;
|
||||
for hdr_on in [false, true] {
|
||||
let globals = super::super::Globals {
|
||||
@@ -677,7 +677,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -739,7 +739,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
|
||||
let source = include_str!("../hdr_light.wgsl").to_owned()
|
||||
+ include_str!("stars.wgsl")
|
||||
@@ -750,7 +750,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
@fragment fn reference() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
}
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
}
|
||||
@@ -763,7 +763,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
|
||||
let mut data = [0.0_f32; 32];
|
||||
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
|
||||
data[20..24].copy_from_slice(&settings);
|
||||
data[24..28].copy_from_slice(&gains);
|
||||
data[24..28].copy_from_slice(&shares);
|
||||
data[31] = gain;
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
@@ -908,7 +908,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let mut count = 0;
|
||||
for fog in [0.0, 0.5, 1.0] {
|
||||
for visibility in [1.0, 0.4] {
|
||||
@@ -921,7 +921,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for terrain in [true, false] {
|
||||
@@ -968,7 +968,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
@@ -977,7 +977,7 @@ fn actual_translucent_writers_blend_their_own_light() {
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
|
||||
@@ -1035,14 +1035,14 @@ fn unmarked_entity_users_keep_their_source_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 255);
|
||||
@@ -1079,13 +1079,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
|
||||
return;
|
||||
}
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [1.0, 1.0, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
let draw = Draw::new(&device, &queue, false, &globals, 16);
|
||||
@@ -1129,7 +1129,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
}))
|
||||
.unwrap();
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let (settings, gains) = hdr.draw_params(true);
|
||||
let (settings, shares) = hdr.draw_params(true);
|
||||
let globals = super::super::Globals {
|
||||
fog: [0.0, 100.0, 0.0, 100.0],
|
||||
fog_ends: [100.0, 100.0, 0.0, 0.0],
|
||||
@@ -1138,7 +1138,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
|
||||
hdr_windows: hdr.windows(),
|
||||
hdr_light: [0.8, 0.8, 1.0, 1.0],
|
||||
hdr_settings: settings,
|
||||
hdr_gains: gains,
|
||||
hdr_shares: shares,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
for &alpha in alphas {
|
||||
|
||||
@@ -2770,8 +2770,6 @@ file = \"b.wgsl\"
|
||||
true,
|
||||
);
|
||||
(display.paper_white, display.peak) = (paper, peak);
|
||||
// As the renderer has it under a pack that draws: Horizon lights none of it.
|
||||
display.lighting = false;
|
||||
if switched {
|
||||
use super::super::hdr::Output::{Pq, ScRgb};
|
||||
display = display.retargeted(&device, &queue, ScRgb);
|
||||
|
||||
@@ -47,7 +47,7 @@ struct Uniform {
|
||||
clip_from_sky: [[f32; 4]; 4],
|
||||
color: [f32; 4],
|
||||
hdr_settings: [f32; 4],
|
||||
hdr_gains: [f32; 4],
|
||||
hdr_shares: [f32; 4],
|
||||
hdr_light: [f32; 4],
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ impl Stars {
|
||||
clip_from_sky: clip_from_sky.to_cols_array_2d(),
|
||||
color: [b; 4],
|
||||
hdr_settings: globals.hdr_settings,
|
||||
hdr_gains: globals.hdr_gains,
|
||||
hdr_shares: globals.hdr_shares,
|
||||
hdr_light: globals.hdr_light,
|
||||
};
|
||||
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
|
||||
|
||||
@@ -5,7 +5,7 @@ struct Stars {
|
||||
// starBrightness in every channel (RenderSystem.setShaderColor).
|
||||
color: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
hdr_light: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> stars: Stars;
|
||||
@@ -20,7 +20,7 @@ struct Stars {
|
||||
@fragment fn fs_stars() -> @location(0) vec4<f32> {
|
||||
let b = stars.color.a;
|
||||
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
|
||||
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
|
||||
}
|
||||
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
|
||||
return vec4(stars.color.rgb * b, 0.175);
|
||||
|
||||
@@ -360,13 +360,13 @@ impl Post {
|
||||
&mut self,
|
||||
gain: bool,
|
||||
settings: [f32; 4],
|
||||
gains: [f32; 4],
|
||||
shares: [f32; 4],
|
||||
windows: [[f32; 4]; 2],
|
||||
) {
|
||||
// These uniforms do not change baked colour without gain. Keep compatibility
|
||||
// history stable when only its final display settings change.
|
||||
let key = Some(if gain {
|
||||
(true, settings, gains, windows)
|
||||
(true, settings, shares, windows)
|
||||
} else {
|
||||
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
|
||||
});
|
||||
@@ -887,7 +887,7 @@ mod tests {
|
||||
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
|
||||
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
|
||||
eprintln!(
|
||||
"TAA baked-light GPU checked gain, all settings/gains/windows and the first frame after a pack"
|
||||
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
|
||||
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
}
|
||||
return frame_stored(finished);
|
||||
}
|
||||
@@ -20,20 +20,15 @@ struct Params {
|
||||
black: f32,
|
||||
// The HUD and menus' brightness as a share of paper white.
|
||||
ui: f32,
|
||||
// The compatibility frame's lift (`lit`; the float frame's writers have theirs from
|
||||
// Hdr::draw_params). HDR lighting: what the strongest light multiplies a surface by, 1 for
|
||||
// none.
|
||||
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
|
||||
scene: f32,
|
||||
// Per tier, low to very bright (hdr.rs `COMPAT_SHARES`): the share of the headroom
|
||||
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
|
||||
// its fully lifted pixels get.
|
||||
share: vec4<f32>,
|
||||
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
|
||||
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
|
||||
// a share of the height.
|
||||
calibration: vec4<f32>,
|
||||
// The scene's peak in units of paper white where Horizon lifts the world (hdr.rs
|
||||
// `SCENE_PEAK`), else 0: what the tone curve maps onto the display's peak (`tone_scale`).
|
||||
scene_peak: f32,
|
||||
};
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@@ -160,7 +155,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
|
||||
fn lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> Lit {
|
||||
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
|
||||
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), params.share);
|
||||
}
|
||||
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
@@ -256,52 +251,33 @@ fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
|
||||
return display(light, pos);
|
||||
}
|
||||
|
||||
// Where the scene's peak is unknown (`tone_scale`), the curve's knee: up to it light stays as it
|
||||
// is. Where HDR lighting eased into the peak before the curve (`lit`), 60% of it, so bright
|
||||
// textures keep their detail; at least paper white.
|
||||
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
|
||||
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
|
||||
fn tone_knee(headroom: f32) -> f32 {
|
||||
return max(0.6 * headroom, 1.0);
|
||||
}
|
||||
|
||||
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper` nits:
|
||||
// what to scale the light by, so it ends at most `headroom` (T). ITU-R BT.2390's EETF in PQ,
|
||||
// without the black level lift (`display` does that): the same up to its knee, then a Hermite
|
||||
// spline reaching T at the source's peak, past which light shows at T. The brightest channel sets
|
||||
// the scale for all three, so light keeps its hue (lava stays orange) and no channel passes T.
|
||||
//
|
||||
// The source is the scene's peak S where Horizon lifts the world (`scene_peak`, hdr.rs
|
||||
// `SCENE_PEAK`): the world's light is the same on every display, light past S is held at S, and
|
||||
// where T reaches S that is all, so a display above S shows the world as one at S. Where the
|
||||
// scene's range is unknown (`scene_peak` 0: a 0.2 pack's own light, HDR Lighting off), the
|
||||
// source follows the display, as the 0.2 contract has it: the peak that puts the EETF's knee at
|
||||
// `tone_knee`, PQ's 10 000 nits at most.
|
||||
fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
|
||||
let peak = pq(vec3(headroom * paper)).x;
|
||||
var source: f32;
|
||||
var e: f32;
|
||||
var held = 1.0;
|
||||
if scene_peak > 0.0 {
|
||||
held = min(1.0, scene_peak / max(top, 1e-6));
|
||||
if scene_peak <= headroom {
|
||||
return held;
|
||||
}
|
||||
source = pq(vec3(scene_peak * paper)).x;
|
||||
e = pq(vec3(min(top, scene_peak) * paper)).x / source;
|
||||
} else {
|
||||
if top <= tone_knee(headroom) {
|
||||
return 1.0;
|
||||
}
|
||||
source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
|
||||
e = pq(vec3(top * paper)).x / source;
|
||||
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper`
|
||||
// nits: what to scale the light by, so it ends at most `headroom` (above 1). ITU-R BT.2390's
|
||||
// EETF in PQ, without the black level lift (`display` does that): the same up to its knee, then
|
||||
// a Hermite spline that reaches the peak level, at the source's peak. That peak is where the knee
|
||||
// lands at `tone_knee`, PQ's 10 000 nits at most; light past it shows at the peak. The brightest
|
||||
// channel sets the scale for all three, so light keeps its hue as it eases (lava stays orange)
|
||||
// and no channel passes the peak.
|
||||
fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
|
||||
if top <= tone_knee(headroom) {
|
||||
return 1.0;
|
||||
}
|
||||
let peak = pq(vec3(headroom * paper)).x;
|
||||
let source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
|
||||
let e = pq(vec3(top * paper)).x / source;
|
||||
let max_lum = peak / source;
|
||||
// At least 0: past 3 to 1 in PQ the knee would leave the source's range.
|
||||
let ks = max(1.5 * max_lum - 0.5, 0.0);
|
||||
let ks = 1.5 * max_lum - 0.5;
|
||||
if e >= 1.0 {
|
||||
return headroom / top;
|
||||
}
|
||||
if e <= ks {
|
||||
return held;
|
||||
return 1.0;
|
||||
}
|
||||
let t = (e - ks) / (1.0 - ks);
|
||||
let t2 = t * t;
|
||||
@@ -311,16 +287,16 @@ fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
|
||||
}
|
||||
|
||||
// Hdr::tonemap, after the post effects and before the camera overlays (docs/design/
|
||||
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) fitted to the display, in
|
||||
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light the curve
|
||||
// leaves alone stays exactly as stored. With no headroom and no scene peak, a clip at white.
|
||||
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) eased into the peak, in
|
||||
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light up to the
|
||||
// knee stays exactly as stored. With no headroom, a clip at white.
|
||||
@fragment fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let g = textureLoad(world, vec2<i32>(pos.xy), 0).rgb;
|
||||
if params.scene_peak == 0.0 && params.peak <= 1.0 {
|
||||
if params.peak <= 1.0 {
|
||||
return vec4(min(g, vec3(1.0)), 1.0);
|
||||
}
|
||||
let top = pow(max(max(g.r, max(g.g, g.b)), 0.0), 2.2);
|
||||
let s = tone_scale(top, params.scene_peak, params.peak, params.paper * 80.0);
|
||||
let s = tone_scale(top, params.peak, params.paper * 80.0);
|
||||
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
|
||||
}
|
||||
|
||||
@@ -355,7 +331,7 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
}
|
||||
|
||||
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
|
||||
// paper white in linear light. Unlike the display curve (which ends at the display's peak),
|
||||
// paper white in linear light. Unlike the display curve (whose knee is at least white),
|
||||
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
|
||||
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
|
||||
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
|
||||
|
||||
@@ -27,8 +27,7 @@ struct Lit {
|
||||
lifted: bool,
|
||||
};
|
||||
|
||||
// `lifts`, per tier: how much its fully lifted pixels gain over their own light (their gain - 1).
|
||||
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, lifts: vec4<f32>) -> Lit {
|
||||
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
|
||||
var lifted = false;
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
@@ -61,7 +60,7 @@ fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4
|
||||
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
var gain = 1.0 + lifts[tier] * lift;
|
||||
var gain = 1.0 + (settings.x - 1.0) * shares[tier] * lift;
|
||||
if eased {
|
||||
gain = min(gain, settings.x / max(top, 1e-4));
|
||||
}
|
||||
@@ -89,8 +88,7 @@ fn hdr_quantized_mark(mark: f32) -> f32 {
|
||||
return round(saturate(mark) * 255.0) / 255.0;
|
||||
}
|
||||
|
||||
// The world's writers' light, from the tiers' `gains` (Hdr::draw_params): the same on every
|
||||
// display; the tone curve fits it to the display afterwards (fs_tonemap).
|
||||
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, gains: vec4<f32>) -> vec3<f32> {
|
||||
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), settings.z > 0.5, false, settings, gains - vec4(1.0)).light, vec3(1.0 / 2.2));
|
||||
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, shares: vec4<f32>) -> vec3<f32> {
|
||||
let shows = settings.z > 0.5 && settings.x > 1.0;
|
||||
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), shows, false, settings, shares).light, vec3(1.0 / 2.2));
|
||||
}
|
||||
@@ -24,7 +24,7 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
|
||||
var color = oit_cloud_color(in, front, true);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
color = vec4(hdr_written(color.rgb, hdr_scene_alpha(globals.hdr_light.y),
|
||||
globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
|
||||
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
|
||||
var color = celestial_color(in);
|
||||
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
|
||||
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
|
||||
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
|
||||
}
|
||||
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
|
||||
globals.hdr_light.y, f * globals.fog_color.a));
|
||||
// Convert the surface before weighting; its mark never belongs to the light behind it.
|
||||
let fogged = mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a);
|
||||
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_gains) * weighted.a, weighted.a);
|
||||
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_shares) * weighted.a, weighted.a);
|
||||
}
|
||||
return vec4(rgb, weighted.a);
|
||||
}
|
||||
|
||||
@@ -1100,9 +1100,6 @@ impl PackScreen {
|
||||
0,
|
||||
);
|
||||
self.search.draw(&mut out, m, blink_ms, None);
|
||||
if self.browser.is_none() && self.search.contains(f64::from(mouse.0), f64::from(mouse.1)) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
for side in 0..2 {
|
||||
self.draw_list(&mut out, m, side, mouse);
|
||||
}
|
||||
@@ -1305,9 +1302,6 @@ impl PackScreen {
|
||||
field.x = qx;
|
||||
field.y = qy;
|
||||
field.draw(out, m, 0, None);
|
||||
if field.contains(f64::from(mouse.0), f64::from(mouse.1)) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
let (sbx, sby) = self.browser_search_button();
|
||||
d::button(
|
||||
out,
|
||||
|
||||
@@ -310,9 +310,6 @@ pub(crate) fn search_field(
|
||||
let frame = [x, y, clear[0] - x, h];
|
||||
search.frame = Some(frame);
|
||||
search.draw(batch, atlas, None);
|
||||
if ui::hovered(cursor, x, y, frame[2], h) {
|
||||
crate::widgets::text_cursor(true, true);
|
||||
}
|
||||
if search.value.is_empty() && !focused {
|
||||
let hint = Text::literal(search.message.clone());
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
|
||||
|
||||
@@ -43,7 +43,7 @@ struct Globals {
|
||||
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
|
||||
hdr_light: vec4<f32>,
|
||||
hdr_settings: vec4<f32>,
|
||||
hdr_gains: vec4<f32>,
|
||||
hdr_shares: vec4<f32>,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> globals: Globals;
|
||||
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
|
||||
@@ -788,7 +788,7 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
|
||||
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_gains), 1.0);
|
||||
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
}
|
||||
return frame_stored(vec4(finished.rgb, 1.0));
|
||||
}
|
||||
@@ -870,7 +870,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
|
||||
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
|
||||
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
|
||||
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged));
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
|
||||
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
|
||||
}
|
||||
return frame_stored(finished);
|
||||
}
|
||||
@@ -1076,7 +1076,7 @@ fn sky_finished(color: vec3<f32>) -> vec4<f32> {
|
||||
let mark = 1.0 - 0.24 * globals.hdr_light.y;
|
||||
if HDR_WRITE_LIGHT {
|
||||
if globals.hdr_light.w != 0.0 {
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_gains), 1.0);
|
||||
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
|
||||
}
|
||||
return frame_stored(vec4(color, 1.0));
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ pub enum Cursor {
|
||||
#[default]
|
||||
Arrow,
|
||||
PointingHand,
|
||||
/// `CursorTypes.IBEAM`: over a text field that takes input.
|
||||
Ibeam,
|
||||
NotAllowed,
|
||||
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
|
||||
ResizeNs,
|
||||
@@ -57,18 +55,6 @@ pub fn slider_cursor(hovered: bool, active: bool, dragging: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `EditBox.handleCursor`: a hovered text field shows the I-beam, or the
|
||||
/// forbidden sign when it takes no input.
|
||||
pub fn text_cursor(hovered: bool, editable: bool) {
|
||||
if hovered {
|
||||
request_cursor(if editable {
|
||||
Cursor::Ibeam
|
||||
} else {
|
||||
Cursor::NotAllowed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The cursor the frame asked for, and back to the arrow for the next one
|
||||
/// (the window shows the arrow unless a widget asks).
|
||||
pub fn take_cursor() -> Cursor {
|
||||
@@ -443,16 +429,6 @@ pub fn record_as(label: &str, [x, y, w, h]: [f32; 4], enabled: bool, kind: Kind)
|
||||
mod cursor_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_hovered_text_field_asks_for_the_i_beam() {
|
||||
let _ = take_cursor();
|
||||
text_cursor(false, true);
|
||||
assert_eq!(take_cursor(), Cursor::Arrow);
|
||||
text_cursor(true, true);
|
||||
assert_eq!(take_cursor(), Cursor::Ibeam);
|
||||
text_cursor(true, false);
|
||||
assert_eq!(take_cursor(), Cursor::NotAllowed);
|
||||
}
|
||||
#[test]
|
||||
fn a_dragged_slider_asks_for_the_horizontal_resize_cursor() {
|
||||
let _ = take_cursor();
|
||||
|
||||
@@ -73,8 +73,6 @@ horizon-theme = { path = "../horizon-theme" }
|
||||
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
# The machine and process probes (the dedicated server's performance report).
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
crc32fast = "1"
|
||||
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
|
||||
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
|
||||
|
||||
@@ -40,17 +40,6 @@ pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
|
||||
.is_some_and(|data| data.get("RecordItem").is_some())
|
||||
}
|
||||
|
||||
/// `LecternBlock.getStateForPlacement`: the item's `minecraft:block_entity_data`
|
||||
/// carries a `Book` (`TypedEntityData.contains` checks the key only), so the
|
||||
/// placed lectern's `has_book` state says so. Vanilla reads the item's data,
|
||||
/// not the block entity the placement just filled.
|
||||
pub fn carries_book(stack: &crate::items::ItemStack) -> bool {
|
||||
stack
|
||||
.data()
|
||||
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
|
||||
.is_some_and(|data| data.get("Book").is_some())
|
||||
}
|
||||
|
||||
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
|
||||
/// the item carries data of an op-only type.
|
||||
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
|
||||
|
||||
@@ -139,8 +139,6 @@ pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod studio;
|
||||
pub mod telemetry;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_fixtures;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
|
||||
@@ -84,7 +84,6 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
|
||||
mod fluids;
|
||||
mod item_fall;
|
||||
mod item_no_physics;
|
||||
pub mod nameplate;
|
||||
mod obstacles;
|
||||
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
|
||||
pub use obstacles::ObstacleGrid;
|
||||
@@ -1290,41 +1289,8 @@ pub struct ExperienceOrb {
|
||||
/// `ExperienceOrb.followingPlayer`: the followed player's entity id, kept
|
||||
/// until they are gone or further than 8 blocks. Never saved.
|
||||
pub following: Option<u32>,
|
||||
/// `Entity.fallDistance` (`fall_distance` in its save).
|
||||
pub fall_distance: f64,
|
||||
}
|
||||
/// An orb's `Entity.move` this tick, for the fall bookkeeping its caller
|
||||
/// finishes (the long-move cast, `checkFallDamage`'s HIT_GROUND).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct OrbMove {
|
||||
pub from: Vec3,
|
||||
pub moved: Vec3,
|
||||
/// `Entity.isInWater` from this tick's fluid step.
|
||||
pub in_water: bool,
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
/// A fresh orb (`new ExperienceOrb(level, pos, motion, value)`): full
|
||||
/// health, no age, fall or followed player, not yet ticked.
|
||||
pub fn new(id: u32, position: Vec3, velocity: Vec3, value: i32) -> Self {
|
||||
Self {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
id,
|
||||
position,
|
||||
previous: position,
|
||||
velocity,
|
||||
value,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
air: 300,
|
||||
no_gravity: false,
|
||||
following: None,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
|
||||
/// `int`; an orb at 0 or below is discarded. True when it is gone.
|
||||
pub fn hurt(&mut self, damage: f32) -> bool {
|
||||
@@ -1448,7 +1414,7 @@ impl ExperienceOrb {
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
) -> bool {
|
||||
let (stays, _) = self.tick_among(
|
||||
self.tick_among(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
@@ -1459,12 +1425,10 @@ impl ExperienceOrb {
|
||||
fluids,
|
||||
on_sound,
|
||||
&[],
|
||||
);
|
||||
stays
|
||||
)
|
||||
}
|
||||
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
|
||||
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
|
||||
/// Also gives the move it made, whose fall bookkeeping the caller finishes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn tick_among(
|
||||
&mut self,
|
||||
@@ -1478,12 +1442,12 @@ impl ExperienceOrb {
|
||||
fluids: &crate::motion::FluidResources,
|
||||
on_sound: &mut dyn FnMut([f64; 3], f32),
|
||||
entities: &[Aabb],
|
||||
) -> (bool, OrbMove) {
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
// `Entity.baseTick`'s fluid step, whose `updateFluidOnEyes` answer the
|
||||
// water branch below reads: the orb's eye, not its feet, decides.
|
||||
let (eye_in_water, in_water) = self.update_fluids(world, fluids);
|
||||
let eye_in_water = self.update_fluids(world, fluids);
|
||||
let block = world.block(
|
||||
self.position.x.floor() as i32,
|
||||
self.position.y.floor() as i32,
|
||||
@@ -1550,7 +1514,6 @@ impl ExperienceOrb {
|
||||
let moved = scene
|
||||
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
let from = self.position;
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
@@ -1598,12 +1561,7 @@ impl ExperienceOrb {
|
||||
self.velocity.y = -wanted.y * 0.4;
|
||||
}
|
||||
self.age += 1;
|
||||
let step = OrbMove {
|
||||
from,
|
||||
moved,
|
||||
in_water,
|
||||
};
|
||||
(!discarded && self.age < 6000, step)
|
||||
!discarded && self.age < 6000
|
||||
}
|
||||
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
|
||||
/// server, through the orb's existing hurt/discard policy, playing the generic
|
||||
@@ -2062,8 +2020,6 @@ pub struct ArmorStand {
|
||||
/// (`getAttribute`) on the stand's first server tick, and a save that lists
|
||||
/// it loads it back, so a stand summoned this tick has none yet.
|
||||
pub speed_instance: bool,
|
||||
/// `NAME_TAG_DISTANCE` and `BELOW_NAME_DISTANCE` from server attribute updates.
|
||||
pub nameplate: crate::entities::nameplate::NameplateDistances,
|
||||
}
|
||||
/// The fluid a stand travels in (`LivingEntity.travelInFluid`: water first,
|
||||
/// then lava), from the heights `Entity::push_by_fluid` measured.
|
||||
@@ -2147,6 +2103,20 @@ impl ArmorStand {
|
||||
}
|
||||
}
|
||||
impl ArmorStand {
|
||||
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
|
||||
/// only a living target changes. Stack consumption belongs to the caller.
|
||||
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:name_tag" {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
if self.health > 0.0 {
|
||||
self.custom_name = Some(crate::component::name_text(name));
|
||||
}
|
||||
true
|
||||
}
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
/// The `Pose` compound's keys.
|
||||
pub const POSE_KEYS: [&'static str; 6] =
|
||||
@@ -2303,7 +2273,6 @@ impl ArmorStand {
|
||||
enchantment_equipment: Default::default(),
|
||||
support: Default::default(),
|
||||
speed_instance: false,
|
||||
nameplate: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `Entity.getOnPos(0.2)` (`getOnPosLegacy`): the supporting block's
|
||||
@@ -3377,7 +3346,6 @@ mod world_border_tests {
|
||||
None,
|
||||
);
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4789,7 +4757,6 @@ mod orb_underwater_tests {
|
||||
.iter()
|
||||
.map(|(v, _)| {
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -4825,7 +4792,6 @@ mod render_light_tests {
|
||||
|
||||
fn orb(y: f64) -> ExperienceOrb {
|
||||
ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -5028,7 +4994,6 @@ mod orb_lava_tests {
|
||||
|
||||
fn sample_orb(at: Vec3, health: i32) -> ExperienceOrb {
|
||||
ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -159,28 +159,19 @@ impl ItemEntity {
|
||||
}
|
||||
impl ExperienceOrb {
|
||||
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
|
||||
/// orb's box and pushes its velocity by any water or lava current; water
|
||||
/// resets its fall, and `Entity.baseTick` halves it in lava (`isInLava`
|
||||
/// is false on the orb's first tick, `Entity.firstTick`). Answers
|
||||
/// `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads, `0.425`
|
||||
/// above the orb's feet, and `isInWater`.
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (bool, bool) {
|
||||
let (water, lava, eye) = update_entity_fluids(
|
||||
/// orb's box and pushes its velocity by any water or lava current.
|
||||
/// Answers `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads,
|
||||
/// `0.425` above the orb's feet.
|
||||
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
|
||||
update_entity_fluids(
|
||||
self.bounds(),
|
||||
self.position,
|
||||
self.position.y + f64::from(0.425f32),
|
||||
&mut self.velocity,
|
||||
world,
|
||||
fluids,
|
||||
);
|
||||
if water > 0.0 {
|
||||
self.fall_distance = 0.0;
|
||||
}
|
||||
// `tick_among` counts this tick first: `tickCount` is 1 on the first.
|
||||
if lava > 0.0 && self.ticks > 1 {
|
||||
self.fall_distance *= 0.5;
|
||||
}
|
||||
(eye, water > 0.0)
|
||||
)
|
||||
.2
|
||||
}
|
||||
}
|
||||
impl ArmorStand {
|
||||
@@ -480,7 +471,6 @@ mod tests {
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
@@ -572,7 +562,6 @@ mod tests {
|
||||
}
|
||||
let at = Vec3::new(20.5, 4.6, 20.5);
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
//! The nameplate attributes carried by armor stands and mobs.
|
||||
use super::ArmorStand;
|
||||
use crate::items::ItemStack;
|
||||
use crate::mobs::Mob;
|
||||
|
||||
/// The two synced attributes that control name tags and below-name lines.
|
||||
/// `None` means the server has not sent a sync for that attribute yet.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct NameplateDistances {
|
||||
/// `minecraft:name_tag_distance` from the server's attribute updates.
|
||||
pub name_tag: Option<f64>,
|
||||
/// `minecraft:below_name_distance` from the server's attribute updates.
|
||||
pub below_name: Option<f64>,
|
||||
}
|
||||
|
||||
impl NameplateDistances {
|
||||
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`: the
|
||||
/// default is 64 and vanilla's maximum is 512.
|
||||
pub fn name_tag_distance(&self) -> f64 {
|
||||
name_tag_distance_of(self.name_tag)
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`: the
|
||||
/// default is 10 and vanilla's maximum is 512.
|
||||
pub fn below_name_distance(&self) -> f64 {
|
||||
below_name_distance_of(self.below_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mob {
|
||||
/// Stores both distances, from a model's own live attributes.
|
||||
pub fn set_nameplate(&mut self, distances: NameplateDistances) {
|
||||
let attributes = self.extra.attributes.get_or_insert_with(Default::default);
|
||||
attributes.name_tag_distance = distances.name_tag;
|
||||
attributes.below_name_distance = distances.below_name;
|
||||
}
|
||||
|
||||
/// Stores `NAME_TAG_DISTANCE` from a server attribute update.
|
||||
pub fn set_name_tag_distance(&mut self, value: f64) {
|
||||
self.extra
|
||||
.attributes
|
||||
.get_or_insert_with(Default::default)
|
||||
.name_tag_distance = Some(value);
|
||||
}
|
||||
|
||||
/// Stores `BELOW_NAME_DISTANCE` from a server attribute update.
|
||||
pub fn set_below_name_distance(&mut self, value: f64) {
|
||||
self.extra
|
||||
.attributes
|
||||
.get_or_insert_with(Default::default)
|
||||
.below_name_distance = Some(value);
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
|
||||
pub fn name_tag_distance(&self) -> f64 {
|
||||
name_tag_distance_of(
|
||||
self.extra
|
||||
.attributes
|
||||
.as_ref()
|
||||
.and_then(|attributes| attributes.name_tag_distance),
|
||||
)
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
|
||||
pub fn below_name_distance(&self) -> f64 {
|
||||
below_name_distance_of(
|
||||
self.extra
|
||||
.attributes
|
||||
.as_ref()
|
||||
.and_then(|attributes| attributes.below_name_distance),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ArmorStand {
|
||||
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
|
||||
/// only a living target changes. Stack consumption belongs to the caller.
|
||||
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:name_tag" {
|
||||
return false;
|
||||
}
|
||||
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
if self.health > 0.0 {
|
||||
self.custom_name = Some(crate::component::name_text(name));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
|
||||
pub fn name_tag_distance(&self) -> f64 {
|
||||
self.nameplate.name_tag_distance()
|
||||
}
|
||||
|
||||
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
|
||||
pub fn below_name_distance(&self) -> f64 {
|
||||
self.nameplate.below_name_distance()
|
||||
}
|
||||
}
|
||||
|
||||
/// The vanilla default (64) and maximum (512) for `NAME_TAG_DISTANCE`.
|
||||
pub fn name_tag_distance_of(value: Option<f64>) -> f64 {
|
||||
value
|
||||
.filter(|v| v.is_finite())
|
||||
.unwrap_or(64.0)
|
||||
.clamp(0.0, 512.0)
|
||||
}
|
||||
|
||||
/// The vanilla default (10) and maximum (512) for `BELOW_NAME_DISTANCE`.
|
||||
pub fn below_name_distance_of(value: Option<f64>) -> f64 {
|
||||
value
|
||||
.filter(|v| v.is_finite())
|
||||
.unwrap_or(10.0)
|
||||
.clamp(0.0, 512.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
#[test]
|
||||
fn nameplate_distances_use_vanilla_defaults_and_clamp() {
|
||||
assert_eq!(name_tag_distance_of(None), 64.0);
|
||||
assert_eq!(name_tag_distance_of(Some(5.0)), 5.0);
|
||||
assert_eq!(name_tag_distance_of(Some(600.0)), 512.0);
|
||||
assert_eq!(name_tag_distance_of(Some(-4.0)), 0.0);
|
||||
assert_eq!(below_name_distance_of(None), 10.0);
|
||||
assert_eq!(below_name_distance_of(Some(2.0)), 2.0);
|
||||
assert_eq!(below_name_distance_of(Some(-1.0)), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armor_stand_nameplate_values_override_defaults_and_clamp() {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
assert_eq!(stand.name_tag_distance(), 64.0);
|
||||
assert_eq!(stand.below_name_distance(), 10.0);
|
||||
stand.nameplate = NameplateDistances {
|
||||
name_tag: Some(5.0),
|
||||
below_name: Some(2.0),
|
||||
};
|
||||
assert_eq!(
|
||||
(stand.name_tag_distance(), stand.below_name_distance()),
|
||||
(5.0, 2.0)
|
||||
);
|
||||
stand.nameplate.name_tag = Some(600.0);
|
||||
assert_eq!(stand.name_tag_distance(), 512.0);
|
||||
}
|
||||
}
|
||||
@@ -2360,8 +2360,8 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
|
||||
m.extra.tame = m.extra.owner.is_some();
|
||||
crate::mobs::tame::restore_sitting(&mut m, flag(tag, "Sitting"));
|
||||
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
|
||||
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Panda {
|
||||
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
|
||||
@@ -2386,8 +2386,8 @@ pub fn mob_from_nbt_with_random(
|
||||
.and_then(crate::save::item_from_nbt);
|
||||
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
|
||||
m.extra.body_extra = armor.and_then(|s| s.extra);
|
||||
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
|
||||
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
|
||||
m.extra.color =
|
||||
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
|
||||
}
|
||||
if matches!(
|
||||
m.species(),
|
||||
@@ -2640,7 +2640,7 @@ pub fn mob_from_nbt_with_random(
|
||||
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Shulker {
|
||||
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) as i8 % 6).unsigned_abs();
|
||||
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
|
||||
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
|
||||
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
|
||||
m.anim.v[0] = m.extra.counter as f32 * 0.01;
|
||||
@@ -2901,7 +2901,7 @@ pub fn entity_base_tag(
|
||||
motion,
|
||||
rotation,
|
||||
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
|
||||
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw)
|
||||
}
|
||||
@@ -3037,7 +3037,6 @@ pub fn arrow_to_nbt(
|
||||
}
|
||||
for (key, value) in [
|
||||
("Fire", Tag::Short(a.fire_ticks as i16)),
|
||||
("Air", Tag::Short(p.air_supply as i16)),
|
||||
("life", Tag::Short(a.life as i16)),
|
||||
("shake", Tag::Byte(a.shake as i8)),
|
||||
("inGround", Tag::Byte(i8::from(a.in_ground))),
|
||||
@@ -3116,7 +3115,6 @@ pub fn arrow_from_nbt(
|
||||
p.owner_uuid = owner;
|
||||
p.no_gravity = flag(tag, "NoGravity");
|
||||
p.on_ground = flag(tag, "OnGround");
|
||||
projectile_fields::load_arrow_entity_fields(&mut p, tag);
|
||||
let a = p.arrow.as_mut()?;
|
||||
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
|
||||
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
|
||||
@@ -3136,6 +3134,8 @@ pub fn arrow_from_nbt(
|
||||
_ => false,
|
||||
})
|
||||
.cloned();
|
||||
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
|
||||
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
a.spectral = spectral;
|
||||
// `SpectralArrow.readAdditionalSaveData`: absent Duration defaults to 200.
|
||||
if spectral {
|
||||
@@ -4162,30 +4162,6 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// `Shulker` reads `AttachFace` as a byte before
|
||||
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
|
||||
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
|
||||
#[test]
|
||||
fn a_shulkers_attach_face_is_narrowed_to_a_byte_first() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = "minecraft:shulker".into();
|
||||
ty.species = crate::mobs::Species::Shulker;
|
||||
let types = [("minecraft:shulker".into(), kind)].into();
|
||||
let face = |saved: Tag| {
|
||||
let raw = Tag::compound([
|
||||
("id", string("minecraft:shulker")),
|
||||
("Pos", doubles(&[4.5, 220.0, 4.5])),
|
||||
("AttachFace", saved),
|
||||
]);
|
||||
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant3
|
||||
};
|
||||
assert_eq!(face(Tag::Byte(3)), 3);
|
||||
assert_eq!(face(Tag::Byte(-2)), 2);
|
||||
assert_eq!(face(Tag::Int(258)), 2);
|
||||
assert_eq!(face(Tag::Int(250)), 0);
|
||||
}
|
||||
use super::*;
|
||||
use crate::items::ItemStack;
|
||||
|
||||
@@ -4233,33 +4209,6 @@ mod tests {
|
||||
assert_eq!(tag.get("UUID"), Some(&Tag::IntArray(vec![9, 9, 9, 9])));
|
||||
}
|
||||
|
||||
/// An entity that never moves (an `AreaEffectCloud`) saves the
|
||||
/// `fall_distance` `Entity.load` read, and 0 when it was made in play.
|
||||
#[test]
|
||||
fn a_base_tag_keeps_the_loaded_fall_distance() {
|
||||
let raw = horizon_world::nbt::parse_snbt(
|
||||
"{id:\"minecraft:area_effect_cloud\",Pos:[1d,200d,1d],fall_distance:5.0d}",
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
let fall = |raw: Option<&super::Tag>| {
|
||||
let tag = super::entity_base_tag(
|
||||
"minecraft:area_effect_cloud",
|
||||
super::Vec3::new(1.0, 200.0, 1.0),
|
||||
super::Vec3::ZERO,
|
||||
[0.0, 0.0],
|
||||
[1; 4],
|
||||
raw,
|
||||
);
|
||||
match tag.get("fall_distance") {
|
||||
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
|
||||
other => panic!("no fall_distance: {other:?}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(fall(Some(&raw)), 5.0);
|
||||
assert_eq!(fall(None), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_saved_tags_are_omitted_without_changing_raw_or_nonempty_tags() {
|
||||
let base = Base {
|
||||
@@ -4590,35 +4539,6 @@ mod tests {
|
||||
assert_eq!(m.extra.head.as_deref(), Some("minecraft:iron_helmet"));
|
||||
}
|
||||
|
||||
/// `Wolf` and `Cat` read `CollarColor` with `DyeColor.LEGACY_ID_CODEC`: a
|
||||
/// byte through `DyeColor.byId`, whose out-of-range answer is the first
|
||||
/// colour, white (`OutOfBoundsStrategy.ZERO`). Nothing wraps.
|
||||
#[test]
|
||||
fn a_collar_colour_outside_the_sixteen_reads_white() {
|
||||
for (species, id) in [
|
||||
(crate::mobs::Species::Wolf, "minecraft:wolf"),
|
||||
(crate::mobs::Species::Cat, "minecraft:cat"),
|
||||
] {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
ty.id = id.into();
|
||||
ty.species = species;
|
||||
let types = [(id.into(), kind)].into();
|
||||
let colour = |saved: Option<Tag>| {
|
||||
let mut fields = vec![("id", string(id)), ("Pos", doubles(&[4.5, 220.0, 4.5]))];
|
||||
fields.extend(saved.map(|c| ("CollarColor", c)));
|
||||
let m = mob_from_nbt(&Tag::compound(fields), &types, 1, None).unwrap();
|
||||
m.extra.color
|
||||
};
|
||||
assert_eq!(colour(Some(Tag::Byte(11))), 11, "{id}");
|
||||
assert_eq!(colour(Some(Tag::Byte(17))), 0, "{id}: not 17 & 15");
|
||||
assert_eq!(colour(Some(Tag::Byte(-1))), 0, "{id}: not 15");
|
||||
// Codec.BYTE narrows an int first: 259 is byte 3.
|
||||
assert_eq!(colour(Some(Tag::Int(259))), 3, "{id}");
|
||||
assert_eq!(colour(None), crate::mobs::tame::RED, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
fn fused_creeper(fuse: Option<i16>) -> (Mob, Tag) {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let ty = Arc::make_mut(&mut kind);
|
||||
@@ -4862,7 +4782,6 @@ mod tests {
|
||||
no_gravity: true,
|
||||
on_ground: true,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: TRIDENT_HIT_GROUND,
|
||||
item: &trident,
|
||||
},
|
||||
@@ -5868,7 +5787,6 @@ mod tests {
|
||||
no_gravity: false,
|
||||
on_ground: false,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: TRIDENT_HIT_GROUND,
|
||||
item: &trident,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Experience orbs: `ExperienceOrb` save data.
|
||||
use super::{
|
||||
Base, ExperienceOrb, Tag, flag, float, int_value, motion, number, position, put, rotation,
|
||||
};
|
||||
use super::{Base, ExperienceOrb, Tag, flag, int_value, motion, number, position, put, rotation};
|
||||
|
||||
/// `ExperienceOrb`: Health, Age, Value, Count.
|
||||
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
@@ -12,7 +10,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
|
||||
motion: o.velocity,
|
||||
rotation: o.rotation,
|
||||
on_ground: o.on_ground,
|
||||
fall_distance: o.fall_distance,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(None);
|
||||
for (key, value) in [
|
||||
@@ -37,7 +35,6 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
|
||||
};
|
||||
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
|
||||
let orb = ExperienceOrb {
|
||||
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
|
||||
rotation: rotation(tag),
|
||||
join: Default::default(),
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
|
||||
@@ -45,17 +45,3 @@ pub(super) fn load_projectile_flags_and_entity_fields(p: &mut Projectile, tag: &
|
||||
p.no_gravity = flag(tag, "NoGravity");
|
||||
load_projectile_entity_fields(p, tag);
|
||||
}
|
||||
|
||||
/// `Entity.load` for an arrow: fire and fall distance live on its arrow state,
|
||||
/// air on the projectile and defaults to the 300 of `getMaxAirSupply`.
|
||||
pub(super) fn load_arrow_entity_fields(p: &mut Projectile, tag: &Tag) {
|
||||
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(300);
|
||||
if let Some(a) = p.arrow.as_mut() {
|
||||
a.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
|
||||
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "projectile_fields_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,17 +0,0 @@
|
||||
use crate::entity_nbt::{arrow_from_nbt, arrow_to_nbt};
|
||||
use horizon_world::nbt::{Tag, parse_snbt};
|
||||
|
||||
/// `Entity.load` reads `Air` and `saveWithoutId` writes it back.
|
||||
#[test]
|
||||
fn arrow_keeps_its_saved_air() {
|
||||
let parse = |text: &str| parse_snbt(text).unwrap().0;
|
||||
let raw = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d],Air:32767s,Fire:7s}");
|
||||
let (arrow, _) = arrow_from_nbt(&raw, None, |_| None).unwrap();
|
||||
assert_eq!(arrow.air_supply, 32767);
|
||||
let saved = arrow_to_nbt(&arrow, [197, 0, 0, 1], None, None, Some(&raw)).unwrap();
|
||||
assert_eq!(saved.get("Air"), Some(&Tag::Short(32767)));
|
||||
assert_eq!(saved.get("Fire"), Some(&Tag::Short(7)));
|
||||
let absent = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d]}");
|
||||
let (fresh, _) = arrow_from_nbt(&absent, None, |_| None).unwrap();
|
||||
assert_eq!(fresh.air_supply, 300);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Thrown tridents: `AbstractArrow`, `Projectile` and `ThrownTrident` save data.
|
||||
use super::{
|
||||
Base, Pickup, Tag, Vec3, flag, float, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
|
||||
Base, Pickup, Tag, Vec3, flag, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
|
||||
position, put, rotation, string, text,
|
||||
};
|
||||
|
||||
@@ -22,8 +22,6 @@ pub struct TridentSave<'a> {
|
||||
/// `Entity.onGround`: a trident moves by `setPos`, so it keeps the loaded one.
|
||||
pub on_ground: bool,
|
||||
pub fire_ticks: i32,
|
||||
/// `Entity.fallDistance`.
|
||||
pub fall_distance: f64,
|
||||
/// `AbstractArrow.soundEvent`: the sound of its next block hit.
|
||||
pub sound_event: &'a str,
|
||||
pub item: &'a crate::items::ItemStack,
|
||||
@@ -40,7 +38,7 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
|
||||
motion: t.motion,
|
||||
rotation: [t.yaw, t.pitch],
|
||||
on_ground: t.on_ground,
|
||||
fall_distance: t.fall_distance,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw);
|
||||
// Projectile.addAdditionalSaveData omits LeftOwner until it is true,
|
||||
@@ -97,8 +95,6 @@ pub struct TridentNbt {
|
||||
pub no_gravity: bool,
|
||||
pub on_ground: bool,
|
||||
pub fire_ticks: i32,
|
||||
/// `Entity.load`: `getDoubleOr("fall_distance", 0.0)`.
|
||||
pub fall_distance: f64,
|
||||
/// `SoundEvent`, or the trident's default when missing or no identifier.
|
||||
pub sound_event: String,
|
||||
pub item: crate::items::ItemStack,
|
||||
@@ -124,7 +120,6 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i16 as i32,
|
||||
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
|
||||
sound_event: text(tag.get("SoundEvent"))
|
||||
.and_then(|s| horizon_client_core::identifier::normalize_id(&s))
|
||||
.unwrap_or_else(|| TRIDENT_HIT_GROUND.to_owned()),
|
||||
|
||||
@@ -26,6 +26,13 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Crates' text in the player's language (`horizon_hooks::tr`).
|
||||
horizon_hooks::engine::set_language(|key| {
|
||||
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
|
||||
});
|
||||
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
|
||||
#[cfg(feature = "friends")]
|
||||
game::register_friends();
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
|
||||
@@ -358,6 +358,7 @@ mod pad;
|
||||
mod pad_actions;
|
||||
mod pad_craft;
|
||||
mod pad_menu;
|
||||
mod page_screen;
|
||||
mod paintings;
|
||||
mod pandas;
|
||||
mod particle_culling;
|
||||
@@ -423,6 +424,7 @@ mod server_links;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod share_view;
|
||||
mod sheep_dye;
|
||||
mod shelf;
|
||||
mod shield;
|
||||
@@ -601,6 +603,8 @@ use resource_loader::{
|
||||
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
|
||||
run_loading, write_report,
|
||||
};
|
||||
#[cfg(feature = "friends")]
|
||||
pub(crate) use share_view::register_friends;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -653,8 +657,7 @@ enum Screen {
|
||||
Advancements,
|
||||
/// WorldOptionsScreen (game/lan_screen.rs).
|
||||
WorldOptions,
|
||||
/// Play with Friends, from the pause menu's Friends button
|
||||
/// (game/friends.rs, game/friends/screen.rs).
|
||||
/// Play with Friends, the page at `Place::PauseFriends` (game/page_screen.rs).
|
||||
Friends,
|
||||
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
|
||||
Social,
|
||||
@@ -1499,8 +1502,8 @@ impl LevelAccess for GameLevel<'_> {
|
||||
signal
|
||||
};
|
||||
}
|
||||
if let Some(signal) = books::lectern_signal(self.world, self.registry, state, pos) {
|
||||
return signal;
|
||||
if let Some(signal) = books::lectern_signal(self.world, pos) {
|
||||
return signal * i32::from(books::has_book_state(self.registry, state));
|
||||
}
|
||||
if let Some(signal) =
|
||||
machines::shelf_signal(self.world, self.registry, state, pos, direction)
|
||||
@@ -2898,9 +2901,6 @@ struct Game<'a> {
|
||||
level_shapes: horizon_client::chunk::LevelShapes,
|
||||
/// This game is that server thread's (game/integrated.rs).
|
||||
serving: Option<integrated::Serving>,
|
||||
/// The open world is shared behind an invite code (game/friends.rs).
|
||||
#[cfg(feature = "friends")]
|
||||
friends: Option<friends::Sharing>,
|
||||
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
|
||||
social: social::Social,
|
||||
world_options: Option<lan_screen::WorldOptions>,
|
||||
@@ -3357,10 +3357,11 @@ impl Game<'_> {
|
||||
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
|
||||
// Minecraft.pick: from the camera entity's eyes along its view
|
||||
// (LivingEntity.getViewYRot: the head's yaw).
|
||||
if let Some(e) = self.camera_entity() {
|
||||
let [x, y, z] = e.position;
|
||||
let eye = [x, y + f64::from(e.eye_height), z];
|
||||
return Some((eye, pick_view_vector(e.y_rot, e.x_rot)));
|
||||
if let Some(m) = self.camera_mob() {
|
||||
return Some((
|
||||
[m.position.x, m.eye_y(), m.position.z],
|
||||
pick_view_vector(m.head_yaw, m.head_pitch),
|
||||
));
|
||||
}
|
||||
Some((self.eye_position()?, pick_view_vector(self.yaw, self.pitch)))
|
||||
}
|
||||
@@ -3403,7 +3404,7 @@ impl Game<'_> {
|
||||
Some(b)
|
||||
}
|
||||
fn outline_context_kind(&self) -> horizon_world::collision_context::EntityContextKind {
|
||||
if self.camera_entity().is_some() {
|
||||
if self.camera_mob().is_some() {
|
||||
horizon_world::collision_context::EntityContextKind::Living
|
||||
} else {
|
||||
horizon_world::collision_context::EntityContextKind::Player
|
||||
@@ -5730,7 +5731,6 @@ impl Game<'_> {
|
||||
if let Some(log) = self.world.block_log.as_mut() {
|
||||
log.extend(held);
|
||||
}
|
||||
span("tick_friends", || self.tick_friends());
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
span("sleep_status", || self.announce_sleep_status());
|
||||
@@ -9065,7 +9065,7 @@ impl Game<'_> {
|
||||
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
|
||||
// A spectated entity's view is not the local player's yaw and pitch
|
||||
// (`Camera.setup` reads the camera entity), so it is kept too.
|
||||
let spectating = self.camera_entity().is_some();
|
||||
let spectating = self.camera_mob().is_some();
|
||||
self.posed_rotation = view
|
||||
.filter(|posed| spectating || Some(*posed) != vanilla)
|
||||
.map(|(_, yaw, pitch)| (yaw, pitch));
|
||||
@@ -11093,6 +11093,7 @@ impl Game<'_> {
|
||||
// the world's own ticks.
|
||||
horizon_client::social::set_presence_status(self.presence_status());
|
||||
horizon_client::social::tick();
|
||||
self.share_tick();
|
||||
// Minecraft.tick: ClientTickEvent.Pre before any of it.
|
||||
self.native_client_tick_pre();
|
||||
if self.title.is_none() && !self.pause {
|
||||
@@ -11833,8 +11834,6 @@ impl<'a> Game<'a> {
|
||||
test_defs_snapshot: false,
|
||||
test_highlights: HashMap::new(),
|
||||
game_tests: game_tests::GameTests::default(),
|
||||
#[cfg(feature = "friends")]
|
||||
friends: None,
|
||||
social: Default::default(),
|
||||
debug_keys: Default::default(),
|
||||
debug_charts: Default::default(),
|
||||
|
||||
@@ -533,8 +533,7 @@ impl Game<'_> {
|
||||
let reach: f64 = self.entity_reach;
|
||||
let mut best: Option<(usize, f64, [f64; 3])> = None;
|
||||
for (i, a) in self.armor_stands.iter().enumerate() {
|
||||
// Minecraft.pick never hits the camera entity it looks from.
|
||||
if a.marker || self.remote_camera_id() == Some(a.id) {
|
||||
if a.marker {
|
||||
continue;
|
||||
}
|
||||
let b = a.bounds();
|
||||
@@ -1438,11 +1437,7 @@ impl Game<'_> {
|
||||
) {
|
||||
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
|
||||
let skinning = self.skinning();
|
||||
for a in self
|
||||
.armor_stands
|
||||
.iter()
|
||||
.filter(|a| !self.hides_as_camera(a.id))
|
||||
{
|
||||
for a in &self.armor_stands {
|
||||
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
|
||||
// edge (4 for a marker's empty box), whatever the entity distance.
|
||||
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! with a redstone pulse, the comparator output and taking the book).
|
||||
use super::{Game, chat::PLAYER_NAME, lan::LanHost, screen_info};
|
||||
use horizon_client::{
|
||||
block_entity_item,
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
hud::{HudBatch, rgba},
|
||||
@@ -19,7 +18,8 @@ use horizon_client::{
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::{level::flags as level_flags, nbt::Tag, registry::BlockStateId};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use horizon_world::nbt::Tag;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -914,32 +914,6 @@ impl Game<'_> {
|
||||
self.set_lectern_powered(key, false);
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.getStateForPlacement`: a lectern placed from an item
|
||||
/// whose `block_entity_data` holds a `Book`, by a placer who may use
|
||||
/// game-master blocks, starts with `has_book` on. It runs before the
|
||||
/// item's `block_state` component, which vanilla applies afterwards and
|
||||
/// which may set `has_book` back. Returns the state to go on with.
|
||||
pub(super) fn lectern_placed(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
state: BlockStateId,
|
||||
stack: &ItemStack,
|
||||
game_master: bool,
|
||||
) -> horizon_client::Result<BlockStateId> {
|
||||
if !game_master
|
||||
|| !block_entity_item::carries_book(stack)
|
||||
|| &*self.res.facts().get(state).name != "minecraft:lectern"
|
||||
{
|
||||
return Ok(state);
|
||||
}
|
||||
match self.with_props(state, &[("has_book", "true")]) {
|
||||
Some(new) if new != state => {
|
||||
self.level_set(pos, new)?;
|
||||
Ok(new)
|
||||
}
|
||||
_ => Ok(state),
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.useItemOn`: a book goes onto an empty lectern.
|
||||
pub(super) fn use_lectern(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
@@ -1712,25 +1686,20 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.getAnalogOutputSignal` and
|
||||
/// `LecternBlockEntity.getRedstoneSignal`: the state's `has_book` gates the
|
||||
/// answer, and an entity with no book reads 14 (a page count of 0 is a page
|
||||
/// progress of 1.0 with no book to add).
|
||||
pub(super) fn lectern_signal(
|
||||
world: &horizon_client::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
state: BlockStateId,
|
||||
pos: BlockPos,
|
||||
) -> Option<i32> {
|
||||
let s = registry.state(state)?;
|
||||
if s.name != "minecraft:lectern" {
|
||||
return None;
|
||||
}
|
||||
if s.properties.get("has_book").is_none_or(|v| v != "true") {
|
||||
return Some(0);
|
||||
}
|
||||
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
|
||||
pub(super) fn has_book_state(
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> bool {
|
||||
registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`.
|
||||
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
|
||||
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(14, |b| {
|
||||
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(0, |b| {
|
||||
let data = b.data();
|
||||
let count = data
|
||||
.and_then(|d| d.written.as_ref().map(|w| w.pages.len()))
|
||||
@@ -1840,10 +1809,6 @@ mod lectern_books_tag_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lectern_tests.rs"]
|
||||
mod lectern_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lectern_signal_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
@@ -1857,26 +1822,19 @@ mod lectern_signal_tests {
|
||||
use horizon_world::level::LevelAccess;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
fn signal(
|
||||
game: &mut super::Game<'_>,
|
||||
has_book: bool,
|
||||
book: Option<ItemStack>,
|
||||
page: i32,
|
||||
) -> i32 {
|
||||
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
|
||||
let key = (8, 64, 8);
|
||||
let registry = game.pack.assets.registry;
|
||||
let default = registry.default_state("minecraft:lectern").unwrap();
|
||||
let mut state = registry.state(default).unwrap().clone();
|
||||
state
|
||||
.properties
|
||||
.insert("has_book".into(), has_book.to_string());
|
||||
let state: BlockStateId = registry.id(&state).unwrap();
|
||||
let state: BlockStateId = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:lectern")
|
||||
.unwrap();
|
||||
game.world.set(key.0, key.1, key.2, state).unwrap();
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(key, BlockEntity::Lectern(Lectern { book, page }));
|
||||
let pos = BlockPos::new(key.0, key.1, key.2);
|
||||
lectern_signal(&game.world, registry, state, pos).unwrap()
|
||||
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
|
||||
}
|
||||
|
||||
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
|
||||
@@ -1928,19 +1886,30 @@ mod lectern_signal_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `LecternBlock.getAnalogOutputSignal` asks for the state's `HAS_BOOK`
|
||||
/// first, so a lectern without it reads 0 whatever its block entity holds;
|
||||
/// with it, `LecternBlockEntity.getRedstoneSignal` is `floor(page /
|
||||
/// (pageCount - 1) * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a
|
||||
/// book-content component, so a stack that only sits in the lectern (a
|
||||
/// non-book item a command left in `Book`) reads 14, not the book's 15.
|
||||
/// An entity with no book at all reads 14 too: a page count of 0 is a page
|
||||
/// progress of 1.0 with no book to add.
|
||||
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
|
||||
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
|
||||
/// component, so a stack that only sits in the lectern (a non-book item a
|
||||
/// command left in `Book`) reads 14, not the book's 15.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_lecterns_signal_adds_one_only_for_a_book() {
|
||||
let mut game = server_game();
|
||||
let writable = Some(ItemStack::new("minecraft:writable_book", 1));
|
||||
assert_eq!(signal(&mut game, None, 0), 0, "no book");
|
||||
assert_eq!(
|
||||
signal(&mut game, Some(ItemStack::new("minecraft:stone", 1)), 0),
|
||||
14,
|
||||
"a non-book stack has no book content"
|
||||
);
|
||||
// `writable_book_content` is a writable book's default component.
|
||||
assert_eq!(
|
||||
signal(
|
||||
&mut game,
|
||||
Some(ItemStack::new("minecraft:writable_book", 1)),
|
||||
0
|
||||
),
|
||||
15,
|
||||
"a writable book is a book"
|
||||
);
|
||||
let mut written = ItemStack::new("minecraft:written_book", 1);
|
||||
written.extra = Some(Box::new(StackData {
|
||||
written: Some(WrittenBook {
|
||||
@@ -1951,25 +1920,8 @@ mod lectern_signal_tests {
|
||||
}),
|
||||
..Default::default()
|
||||
}));
|
||||
// `has_book=false`: no signal, whatever the block entity holds.
|
||||
assert_eq!(signal(&mut game, false, None, 0), 0);
|
||||
assert_eq!(
|
||||
signal(&mut game, false, writable.clone(), 0),
|
||||
0,
|
||||
"state denies"
|
||||
);
|
||||
// `has_book=true`, an entity with no book: page progress 1.0.
|
||||
assert_eq!(signal(&mut game, true, None, 0), 14, "no book");
|
||||
let stone = Some(ItemStack::new("minecraft:stone", 1));
|
||||
assert_eq!(signal(&mut game, true, stone, 0), 14, "no book content");
|
||||
// `writable_book_content` is a writable book's default component.
|
||||
assert_eq!(signal(&mut game, true, writable, 0), 15, "a book");
|
||||
assert_eq!(
|
||||
signal(&mut game, true, Some(written.clone()), 0),
|
||||
1,
|
||||
"page 0"
|
||||
);
|
||||
assert_eq!(signal(&mut game, true, Some(written), 1), 15, "last page");
|
||||
assert_eq!(signal(&mut game, Some(written.clone()), 0), 1, "page 0");
|
||||
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
|
||||
}
|
||||
}
|
||||
impl BookScreen {
|
||||
|
||||
@@ -143,9 +143,6 @@ pub(super) struct CombatTracker {
|
||||
/// A firework rocket's burst is hurting the player now: the rocket is
|
||||
/// the direct entity (`rocket_hurt_player`).
|
||||
pub(super) rocket_hit: bool,
|
||||
/// The arrow hurting the player now pierces (`AbstractArrow.getPierceLevel` above 0):
|
||||
/// `LivingEntity.applyItemBlocking` lets it past a raised shield (`arrow_hits_player`).
|
||||
pub(super) piercing_hit: bool,
|
||||
/// The player whose melee hit is landing on the player now: where they
|
||||
/// stand and the seconds their weapon disables a shield for
|
||||
/// (`getSecondsToDisableBlocking`).
|
||||
|
||||
@@ -2613,14 +2613,23 @@ impl Game<'_> {
|
||||
// `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck like any orb spawn.
|
||||
let at = self.unstuck_orb(pos);
|
||||
self.next_entity += 1;
|
||||
let at = Vec3::new(at[0], at[1], at[2]);
|
||||
let orb = horizon_client::entities::ExperienceOrb::new(
|
||||
self.next_entity,
|
||||
at,
|
||||
Vec3::ZERO,
|
||||
0,
|
||||
);
|
||||
self.orbs.push(orb);
|
||||
self.orbs.push(horizon_client::entities::ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: self.next_entity,
|
||||
position: Vec3::new(at[0], at[1], at[2]),
|
||||
previous: Vec3::new(at[0], at[1], at[2]),
|
||||
velocity: Vec3::new(0.0, 0.0, 0.0),
|
||||
value: 0,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
air: 300,
|
||||
following: None,
|
||||
});
|
||||
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
|
||||
if !self.native_joins_flush(false, self.next_entity) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
|
||||
@@ -646,7 +646,6 @@ impl Game<'_> {
|
||||
orb.count = new.count;
|
||||
orb.health = new.health;
|
||||
orb.no_gravity = new.no_gravity;
|
||||
orb.fall_distance = new.fall_distance;
|
||||
// Entity.load reads portalCooldown and leaves the portal
|
||||
// process as it is.
|
||||
if let Some(portal) = self.portals.entities.get_mut(&id) {
|
||||
|
||||
@@ -1039,7 +1039,14 @@ impl Game<'_> {
|
||||
.unwrap_or(Tag::Compound(Default::default())),
|
||||
v => horizon_client::scoreboard::json_to_tag(v),
|
||||
};
|
||||
set_custom_data(&mut stack, &tag);
|
||||
edit_components(&mut stack, |c| {
|
||||
let mut data = match c.get(&"minecraft:custom_data".into()) {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
merge_compound(&mut data, &tag);
|
||||
c.insert("minecraft:custom_data".into(), data);
|
||||
});
|
||||
}
|
||||
"minecraft:set_components" => {
|
||||
let patch = m["components"].clone();
|
||||
@@ -1570,22 +1577,6 @@ fn edit_components(stack: &mut ItemStack, f: impl FnOnce(&mut horizon_world::nbt
|
||||
stack.extra = extra;
|
||||
stack.tidy();
|
||||
}
|
||||
/// `SetCustomDataFunction.run`: `CustomData.update(stack, tag -> tag.merge(value))`.
|
||||
fn set_custom_data(stack: &mut ItemStack, value: &Tag) {
|
||||
edit_components(stack, |c| {
|
||||
let mut data = match c.get(&"minecraft:custom_data".into()) {
|
||||
Some(t @ Tag::Compound(_)) => t.clone(),
|
||||
_ => Tag::Compound(Default::default()),
|
||||
};
|
||||
merge_compound(&mut data, value);
|
||||
// Vanilla CustomData.update removes the component when the result is empty.
|
||||
if matches!(&data, Tag::Compound(m) if m.is_empty()) {
|
||||
c.remove(&"minecraft:custom_data".into());
|
||||
} else {
|
||||
c.insert("minecraft:custom_data".into(), data);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `CompoundTag.merge`: nested compounds merge, other values replace.
|
||||
fn merge_compound(into: &mut Tag, from: &Tag) {
|
||||
let (Tag::Compound(a), Tag::Compound(b)) = (into, from) else {
|
||||
@@ -2824,16 +2815,3 @@ mod registered_slot_source_preservation_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod set_custom_data_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merging_an_empty_tag_into_a_stack_without_custom_data_adds_no_component() {
|
||||
let mut stack = ItemStack::new("minecraft:stick", 1);
|
||||
set_custom_data(&mut stack, &Tag::Compound(Default::default()));
|
||||
let saved = horizon_client::item_data::encode_components(&stack);
|
||||
assert!(saved.is_none_or(|c| c.get("minecraft:custom_data").is_none()));
|
||||
}
|
||||
}
|
||||
@@ -52,15 +52,12 @@ impl Game<'_> {
|
||||
consumable: &ItemConsumable,
|
||||
stack: &ItemStack,
|
||||
) {
|
||||
// Consumable.onConsume runs on the stack's own component.
|
||||
let consumable = horizon_client::item_sync::stack_consumable(stack, consumable);
|
||||
let consumable = &*consumable;
|
||||
self.emit_consume(consumable, stack, 16);
|
||||
// ServerPlayer: the used statistic and the consume_item trigger.
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
self.trigger_item("minecraft:consume_item", stack);
|
||||
// FoodProperties.onConsume, the player's one ConsumableListener.
|
||||
if let Some((nutrition, saturation)) = horizon_client::item_sync::food(stack, info.food) {
|
||||
if let Some((nutrition, saturation)) = info.food {
|
||||
let at = self.feet();
|
||||
let pitch = self.world.random.triangle_float(1.0, 0.4);
|
||||
self.audio.play(&consumable.sound, at, 1.0, pitch);
|
||||
@@ -99,11 +96,10 @@ impl Game<'_> {
|
||||
// with infinite materials, the hand when emptied, else the inventory
|
||||
// (ServerPlayer.handleExtraItemsCreatedOnUse) or a drop.
|
||||
self.consume_held();
|
||||
if let Some(remainder) =
|
||||
horizon_client::item_sync::use_remainder(stack, info.use_remainder.clone())
|
||||
if let Some(remainder) = &info.use_remainder
|
||||
&& !self.creative
|
||||
{
|
||||
let remainder = ItemStack::new(&remainder, 1);
|
||||
let remainder = ItemStack::new(remainder, 1);
|
||||
if self.held().is_none() {
|
||||
*self.held_mut() = Some(remainder);
|
||||
} else if let Some(rest) = self.give(remainder) {
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod bred_draws;
|
||||
mod effect_instance;
|
||||
use effect_instance::mob_effect_instance;
|
||||
mod mob_geometry;
|
||||
mod mob_group;
|
||||
mod mob_islands;
|
||||
@@ -2312,9 +2310,10 @@ impl Game<'_> {
|
||||
self.post_game_event(event, at, ctx);
|
||||
}
|
||||
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
|
||||
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, a fox's
|
||||
// DRINK, the shulker's lid, an evoker's vexes (ENTITY_PLACE),
|
||||
// Enderman.teleport's TELEPORT, onEquipItem's EQUIP/UNEQUIP.
|
||||
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
|
||||
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
|
||||
// (ENTITY_PLACE), Enderman.teleport's TELEPORT,
|
||||
// LivingEntity.onEquipItem's EQUIP/UNEQUIP.
|
||||
MobEvent::GameEvent {
|
||||
event, at, source, ..
|
||||
} if matches!(
|
||||
@@ -2322,7 +2321,6 @@ impl Game<'_> {
|
||||
"minecraft:entity_action"
|
||||
| "minecraft:prime_fuse"
|
||||
| "minecraft:eat"
|
||||
| "minecraft:drink"
|
||||
| "minecraft:container_open"
|
||||
| "minecraft:container_close"
|
||||
| "minecraft:entity_place"
|
||||
@@ -2534,13 +2532,6 @@ impl Game<'_> {
|
||||
});
|
||||
self.combat.explosion_source = source_mob;
|
||||
self.combat.explosion_source_named = noted;
|
||||
let portal_proof = species == Species::SulfurCube
|
||||
&& source_mob.is_some_and(|id| {
|
||||
self.portals
|
||||
.entities
|
||||
.get(&id)
|
||||
.is_some_and(|p| p.cooldown > 0)
|
||||
});
|
||||
self.combat.explosion_cause = match species {
|
||||
Species::Creeper => Some("minecraft:creeper"),
|
||||
Species::Wither => Some("minecraft:wither"),
|
||||
@@ -2570,11 +2561,7 @@ impl Game<'_> {
|
||||
Species::Creeper | Species::Wither => {
|
||||
self.with_hive_emptying_blast(|g| g.explode(at, radius, source))
|
||||
}
|
||||
Species::SulfurCube => self.explode_sulfur_cube(
|
||||
at,
|
||||
radius,
|
||||
source.with_portal_proof(portal_proof),
|
||||
),
|
||||
Species::SulfurCube => self.explode_sulfur_cube(at, radius, source),
|
||||
_ => self.explode(at, radius, source),
|
||||
};
|
||||
self.combat.explosion_source = None;
|
||||
@@ -3983,7 +3970,6 @@ impl Game<'_> {
|
||||
) -> bool {
|
||||
let post_attack_if_alive = std::mem::take(&mut self.combat.post_attack_if_alive);
|
||||
let rocket = std::mem::take(&mut self.combat.rocket_hit);
|
||||
let pierce = std::mem::take(&mut self.combat.piercing_hit);
|
||||
let melee = self.combat.melee_mob.take();
|
||||
// ServerPlayer.canHarmPlayer refused another player's projectile.
|
||||
if self.creative || self.combat.refuse_player_hit {
|
||||
@@ -3998,7 +3984,7 @@ impl Game<'_> {
|
||||
.damage_tags
|
||||
.get("minecraft:is_projectile")
|
||||
.is_some_and(|t| t.contains(damage_type));
|
||||
let blocking = self.player_blocking(from, damage_type, pierce);
|
||||
let blocking = self.player_blocking(from, damage_type, false);
|
||||
let raised = blocking.is_some();
|
||||
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
|
||||
let attacker = match known {
|
||||
@@ -5321,7 +5307,11 @@ impl Game<'_> {
|
||||
}
|
||||
let hurt = match who {
|
||||
Who::Player => {
|
||||
let hurt = self.arrow_hits_player(amount, p);
|
||||
let hurt = self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:arrow",
|
||||
);
|
||||
// AbstractArrow.onHitEntity: a hit that hurt leaves a
|
||||
// non-piercing arrow stuck in the body.
|
||||
if hurt && p.arrow.as_ref().is_some_and(|a| a.pierce == 0) {
|
||||
@@ -5585,7 +5575,7 @@ impl Game<'_> {
|
||||
if let Some(id) = self.spawn_mob_raw("minecraft:chicken", atp, true)
|
||||
&& let Some(c) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
(c.extra.age, c.extra.variant) = (-24000, p.egg_variant.unwrap_or(0));
|
||||
c.extra.age = -24000;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7148,12 +7138,12 @@ impl Game<'_> {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Projectile.shootFromRotation adds getKnownMovement: what the thrower last moved.
|
||||
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
|
||||
// last moved, not a velocity the server kept for a guest.
|
||||
let on_ground = p.ground.on_ground;
|
||||
let velocity = self.known_player_movement();
|
||||
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
|
||||
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
|
||||
proj.set_egg_item(stack);
|
||||
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
|
||||
self.add_projectile(proj);
|
||||
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
|
||||
@@ -7652,6 +7642,24 @@ fn knock_back(
|
||||
);
|
||||
}
|
||||
|
||||
/// The instance a mob effect gives the player; `None` for a creative or
|
||||
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
|
||||
fn mob_effect_instance(
|
||||
effect: &'static str,
|
||||
ticks: i32,
|
||||
amplifier: i32,
|
||||
ambient: bool,
|
||||
immune: bool,
|
||||
) -> Option<horizon_client::effects::EffectInstance> {
|
||||
if immune && !ambient {
|
||||
return None;
|
||||
}
|
||||
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
|
||||
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
|
||||
instance.ambient = ambient;
|
||||
Some(instance)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod local_sound_event_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/// The instance a mob effect gives the player; `None` for a creative or
|
||||
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
|
||||
pub(super) fn mob_effect_instance(
|
||||
effect: &'static str,
|
||||
ticks: i32,
|
||||
amplifier: i32,
|
||||
ambient: bool,
|
||||
immune: bool,
|
||||
) -> Option<horizon_client::effects::EffectInstance> {
|
||||
if immune && !ambient {
|
||||
return None;
|
||||
}
|
||||
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
|
||||
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
|
||||
instance.ambient = ambient;
|
||||
Some(instance)
|
||||
}
|
||||
@@ -58,7 +58,7 @@ impl Recording {
|
||||
Self {
|
||||
seconds,
|
||||
started: Instant::now(),
|
||||
cpu_s: horizon_system::cpu_seconds(std::process::id()),
|
||||
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
|
||||
spans_were_on,
|
||||
ticks: Vec::with_capacity(seconds as usize * 20),
|
||||
sampled_ticks: 0,
|
||||
@@ -363,7 +363,7 @@ impl Game<'_> {
|
||||
let costs = tick_profile::take();
|
||||
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
|
||||
let jobs = job_stats::take();
|
||||
let cpu = horizon_system::cpu_seconds(std::process::id())
|
||||
let cpu = horizon_bench::cpu_seconds(std::process::id())
|
||||
.zip(r.cpu_s)
|
||||
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
|
||||
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
|
||||
@@ -382,7 +382,7 @@ impl Game<'_> {
|
||||
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
|
||||
"players": players,
|
||||
"levels": self.report_levels(),
|
||||
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
|
||||
"process": process_limits(),
|
||||
"cpu_cores_used": cpu,
|
||||
"config": {
|
||||
@@ -395,12 +395,9 @@ impl Game<'_> {
|
||||
// The machine's description runs commands: off the tick.
|
||||
std::thread::spawn(move || {
|
||||
let mut report = report;
|
||||
let hw = horizon_bench::Hardware::detect();
|
||||
report["machine"] = json!({
|
||||
"os": std::env::consts::OS,
|
||||
"arch": std::env::consts::ARCH,
|
||||
"cpu": horizon_system::cpu(),
|
||||
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
|
||||
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
|
||||
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
|
||||
});
|
||||
match write_report(Path::new("reports"), &report, &text) {
|
||||
Ok(path) => log::info!(
|
||||
|
||||
@@ -37,10 +37,6 @@ pub(super) fn aim_deflect(
|
||||
owner_uuid: Option<[i32; 4]>,
|
||||
guest: Option<u32>,
|
||||
) {
|
||||
// `WindCharge.deflect`: refused while its `noDeflectTicks` last.
|
||||
if !p.deflectable() {
|
||||
return;
|
||||
}
|
||||
if let Some(look) = look {
|
||||
p.velocity = look;
|
||||
p.needs_sync = true;
|
||||
@@ -187,27 +183,6 @@ impl Game<'_> {
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
|
||||
let p = &mut self.creatures.projectiles[i];
|
||||
// `WindCharge.deflect` refuses while its `noDeflectTicks` last: the
|
||||
// blow goes on as an attack that hurts nothing.
|
||||
if p.deflectable() {
|
||||
Self::turn_by_player(p, dir, guest, owner_uuid);
|
||||
}
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
|
||||
}
|
||||
/// `AIM_DEFLECT` by a player's blow: along their view, theirs now.
|
||||
fn turn_by_player(
|
||||
p: &mut Projectile,
|
||||
dir: [f64; 3],
|
||||
guest: Option<u32>,
|
||||
owner_uuid: Option<[i32; 4]>,
|
||||
) {
|
||||
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
|
||||
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
|
||||
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
|
||||
@@ -221,6 +196,14 @@ impl Game<'_> {
|
||||
// at once, not at the fireball's next update ten ticks later.
|
||||
p.acceleration = 0.1;
|
||||
p.needs_sync = true;
|
||||
self.reset_attack_strength();
|
||||
self.swing_arm();
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z]);
|
||||
self.audio
|
||||
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +245,6 @@ mod tests {
|
||||
assert_eq!(b.max_position().y, 66.0);
|
||||
}
|
||||
|
||||
/// A wind charge past its first five ticks (`noDeflectTicks` run out).
|
||||
fn wind_charge() -> Projectile {
|
||||
let mut p = Projectile::new(
|
||||
ProjectileKind::WindCharge,
|
||||
@@ -270,26 +252,9 @@ mod tests {
|
||||
Some(Who::Mob(7)),
|
||||
);
|
||||
p.velocity = Vec3::new(0.0, 1.5, 0.0);
|
||||
p.no_deflect_ticks = 0;
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_wind_charge_is_not_turned() {
|
||||
let mut p = wind_charge();
|
||||
p.no_deflect_ticks = 5;
|
||||
aim_deflect(
|
||||
&mut p,
|
||||
Some(Vec3::new(0.0, -1.0, 0.0)),
|
||||
Some(Who::Player),
|
||||
Some([4; 4]),
|
||||
Some(3),
|
||||
);
|
||||
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
|
||||
assert_eq!(p.owner, Some(Who::Mob(7)));
|
||||
assert!(!p.needs_sync);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
|
||||
let mut p = wind_charge();
|
||||
|
||||
@@ -319,9 +319,7 @@ impl Game<'_> {
|
||||
);
|
||||
let state = self.world.block(origin.x, origin.y, origin.z);
|
||||
let mut used = Vec::new();
|
||||
if let Some(op) = case.get("shield_hit") {
|
||||
used = self.fixture_shield_hit(op)?;
|
||||
} else if let Some(op) = case.get("special_attack") {
|
||||
if let Some(op) = case.get("special_attack") {
|
||||
used = self.fixture_special_attack(op)?;
|
||||
} else if let Some(op) = case.get("wind_burst") {
|
||||
let center = [
|
||||
@@ -499,68 +497,6 @@ impl Game<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Actual player combat and blocking-item side effects, without a window or connection.
|
||||
fn fixture_shield_hit(&mut self, op: &Value) -> Result<Vec<String>> {
|
||||
let stack = parse_stack(op["item"].as_str().ok_or("shield_hit item")?)?
|
||||
.ok_or("shield_hit requires an item")?;
|
||||
let item = stack.item.clone();
|
||||
self.creative = op["creative"].as_bool().unwrap_or(false);
|
||||
self.mode = if self.creative {
|
||||
GameMode::Creative
|
||||
} else {
|
||||
GameMode::Survival
|
||||
};
|
||||
self.player = Some(PlayerMotion::new(
|
||||
self.motion,
|
||||
horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0),
|
||||
)?);
|
||||
self.yaw = 0.0;
|
||||
self.armor = Default::default();
|
||||
self.offhand = Some(stack);
|
||||
self.using = Some((super::shield::OFFHAND_SLOT, 100));
|
||||
self.combat = Default::default();
|
||||
let before = self.stats.get("used", &item);
|
||||
let from = horizon_core::geometry::Vec3::new(
|
||||
0.0,
|
||||
0.0,
|
||||
if op["behind"].as_bool().unwrap_or(false) {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
);
|
||||
let mut arrow = horizon_client::projectiles::Projectile::new(
|
||||
ProjectileKind::Arrow {
|
||||
base_damage: op["damage"].as_f64().unwrap_or(8.0),
|
||||
critical: false,
|
||||
effect: None,
|
||||
},
|
||||
from,
|
||||
None,
|
||||
);
|
||||
arrow.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 1.0);
|
||||
arrow.arrow.as_mut().unwrap().pierce = u8::from(op["pierce"].as_bool().unwrap_or(false));
|
||||
self.projectile_hit(
|
||||
&arrow,
|
||||
horizon_client::projectiles::Hit::Entity {
|
||||
who: horizon_client::mobs::Who::Player,
|
||||
at: horizon_core::geometry::Vec3::ZERO,
|
||||
},
|
||||
)?;
|
||||
let out = vec![format!(
|
||||
"shield health={} used={} stack={}",
|
||||
num(f64::from(self.player.as_ref().unwrap().health.health())),
|
||||
self.stats.get("used", &item) - before,
|
||||
self.offhand
|
||||
.as_ref()
|
||||
.map_or_else(|| "-".to_owned(), |s| format!("{}:{}", s.count, s.damage))
|
||||
)];
|
||||
self.player = None;
|
||||
self.offhand = None;
|
||||
self.using = None;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A player holding the case's item right-clicks the block at `pos`
|
||||
/// (`use_item_as_player`'s block steps): the statistics, the result
|
||||
/// and the held stack, as `VanillaDispenserOracle.use` prints them.
|
||||
|
||||
@@ -909,7 +909,7 @@ impl Game<'_> {
|
||||
};
|
||||
let (Some(slot), true) = (
|
||||
info.equip_slot.as_deref().and_then(Slot::from_name),
|
||||
horizon_client::item_sync::dispensable(stack, info.equip_dispensable),
|
||||
info.equip_dispensable,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
@@ -1240,7 +1240,6 @@ impl Game<'_> {
|
||||
_ => ProjectileKind::Snowball,
|
||||
};
|
||||
let mut shot = Projectile::new(kind, at, None);
|
||||
shot.set_egg_item(&one);
|
||||
// ArrowItem/SpectralArrowItem.asProjectile: dispensed arrows
|
||||
// may be picked up.
|
||||
if let Some(a) = shot.arrow.as_mut() {
|
||||
|
||||
@@ -381,7 +381,7 @@ pub(super) struct Dragon {
|
||||
pub id: u32,
|
||||
pub position: Vec3,
|
||||
pub previous: Vec3,
|
||||
pub(super) velocity: Vec3,
|
||||
velocity: Vec3,
|
||||
pub yaw: f32,
|
||||
pub previous_yaw: f32,
|
||||
yaw_accel: f32,
|
||||
@@ -869,14 +869,14 @@ impl Dragon {
|
||||
/// `ServerExplosion.hurtEntities` knockback on the dragon itself:
|
||||
/// `pushFromExplosion` adds `(1 - dist) * exposure` along the blast to
|
||||
/// its eye to the motion (`Entity.push`); the parts are not pushed.
|
||||
pub(super) fn whole_box(&self) -> Aabb {
|
||||
fn whole_box(&self) -> Aabb {
|
||||
let p = self.position;
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
|
||||
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
|
||||
)
|
||||
}
|
||||
pub(super) fn explosion_knockback(
|
||||
fn explosion_knockback(
|
||||
&self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
@@ -5831,6 +5831,7 @@ mod dragon_fireball_spawn_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thorns_tests.rs"]
|
||||
mod thorns_tests;
|
||||
@@ -2274,7 +2274,6 @@ mod set_rotation_tests {
|
||||
#[test]
|
||||
fn set_rotation_keeps_yaw_and_clamps_pitch_on_kinds_that_had_none() {
|
||||
let mut orb = ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
|
||||
@@ -109,7 +109,6 @@ pub(super) enum BlockInteraction {
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(super) struct ExplosionSource {
|
||||
silent: bool,
|
||||
pub(super) portal_proof: bool,
|
||||
/// The entity that exploded and its kind (`Explosion.getDirectSourceEntity`), when known.
|
||||
entity: Option<(u32, &'static str)>,
|
||||
}
|
||||
@@ -118,10 +117,6 @@ impl ExplosionSource {
|
||||
self.silent = silent;
|
||||
self
|
||||
}
|
||||
pub(super) fn with_portal_proof(mut self, portal_proof: bool) -> Self {
|
||||
self.portal_proof = portal_proof;
|
||||
self
|
||||
}
|
||||
pub(super) fn with_entity(mut self, id: u32, kind: &'static str) -> Self {
|
||||
self.entity = Some((id, kind));
|
||||
self
|
||||
@@ -422,15 +417,10 @@ impl Game<'_> {
|
||||
pos: [i32; 3],
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
cap: Option<f32>,
|
||||
portal_proof: bool,
|
||||
wither_immune: Option<&std::collections::HashSet<String, S>>,
|
||||
) -> (f32, bool) {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let f = self.res.facts().get(state);
|
||||
// USED_PORTAL_DAMAGE_CALCULATOR returns no resistance and never explodes a portal.
|
||||
if portal_proof && &*f.name == "minecraft:nether_portal" {
|
||||
return (0.0, true);
|
||||
}
|
||||
let fluid = if f.fluid != Fluid::None {
|
||||
100.0f32
|
||||
} else {
|
||||
@@ -463,7 +453,6 @@ impl Game<'_> {
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
portal_proof: bool,
|
||||
) -> Vec<[i32; 3]> {
|
||||
let (min_y, max_y) = self.with_level(|_, level, _| {
|
||||
use horizon_world::level::LevelAccess;
|
||||
@@ -512,13 +501,8 @@ impl Game<'_> {
|
||||
let (taken, spared, again) = match last {
|
||||
Some((at, taken, spared)) if at == pos => (taken, spared, true),
|
||||
_ => {
|
||||
let (taken, spared) = self.blast_step(
|
||||
pos,
|
||||
resists,
|
||||
cap,
|
||||
portal_proof,
|
||||
wither_immune.as_ref(),
|
||||
);
|
||||
let (taken, spared) =
|
||||
self.blast_step(pos, resists, cap, wither_immune.as_ref());
|
||||
last = Some((pos, taken, spared));
|
||||
(taken, spared, false)
|
||||
}
|
||||
@@ -1269,7 +1253,7 @@ impl Game<'_> {
|
||||
// Explosion.explode: the blast is heard.
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
self.post_game_event("minecraft:explode", center, ctx);
|
||||
let mut targets = self.exploded_positions(center, radius, resists, source.portal_proof);
|
||||
let mut targets = self.exploded_positions(center, radius, resists);
|
||||
let dimension = self.dim.id();
|
||||
self.native_explosion_detonate(dimension, center, source.entity, &mut targets);
|
||||
let pushed =
|
||||
@@ -1726,7 +1710,7 @@ mod tests {
|
||||
game.world.random.set_seed(7);
|
||||
let expected = exploded_positions_stepwise(&mut game, center, radius, resists);
|
||||
game.world.random.set_seed(7);
|
||||
let got = game.exploded_positions(center, radius, resists, false);
|
||||
let got = game.exploded_positions(center, radius, resists);
|
||||
assert!(expected.len() > 20, "{radius}: {}", expected.len());
|
||||
assert_eq!(got, expected, "radius {radius}, {resists:?}");
|
||||
}
|
||||
|
||||
@@ -756,9 +756,7 @@ impl Game<'_> {
|
||||
.filter_map(|(i, s)| Some((i, s?)))
|
||||
.filter(|(_, s)| counts(s));
|
||||
let (first, stack) = found.next()?;
|
||||
let total = found
|
||||
.map(|(_, s)| s.count)
|
||||
.fold(stack.count, u32::saturating_add);
|
||||
let total = stack.count + found.map(|(_, s)| s.count).sum::<u32>();
|
||||
(ranged || total > 1).then(|| (first, stack.item.clone(), total))
|
||||
}
|
||||
/// One widget's box in `corner`: icons in a column, texts beside them.
|
||||
|
||||
@@ -95,12 +95,12 @@ impl Game<'_> {
|
||||
valid_fov_modifier(modifier, base)
|
||||
});
|
||||
let target = self.zoomed_fov_target(target);
|
||||
// A server's spectator camera on a mob or an armor stand: its eye
|
||||
// height. Its modifier stays 1 (another player's own modifier is not
|
||||
// modelled here); the zoom extra still narrows it.
|
||||
if let Some(e) = self.camera_entity() {
|
||||
self.view
|
||||
.tick(Some(e.eye_height), self.zoomed_fov_target(1.0));
|
||||
// A server's spectator camera on a mob: its eye height. Its modifier
|
||||
// stays 1 (another player's own modifier is not modelled here); the
|
||||
// zoom extra still narrows it.
|
||||
if let Some(m) = self.camera_mob() {
|
||||
let eye = m.eye_height() as f32;
|
||||
self.view.tick(Some(eye), self.zoomed_fov_target(1.0));
|
||||
return;
|
||||
}
|
||||
let eye = self.player.as_ref().map(|p| {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Play with friends, host side: "Start Sharing" on the Play with Friends
|
||||
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
|
||||
//! page (`PAGE`, what the pause menu's Friends icon opens) opens the world to LAN if
|
||||
//! it isn't (with Open to LAN's defaults) and shares that port behind an
|
||||
//! invite code (horizon-friends). The code holds this computer's addresses
|
||||
//! and the key, so friends join with no server of ours; a relay the player
|
||||
@@ -7,18 +7,32 @@
|
||||
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
|
||||
//! loopback address. The joining side is horizon-friends' (`game`), registered by
|
||||
//! horizon-official.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
|
||||
//!
|
||||
//! It sees the game only through `horizon_hooks` (the `ShareView` its `Hooks::share` tick
|
||||
//! gets, `Hooks::lan_closed`, its page, `tr`) and keeps what it shares in its own statics, so
|
||||
//! it can leave the engine for horizon-friends (spec #14167). The `horizon-ctl friends` command
|
||||
//! below still reaches it from `Game` until then.
|
||||
use super::Game;
|
||||
use horizon_client::Result;
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
use horizon_hooks::{
|
||||
Hooks,
|
||||
pages::{Act, Page, Place, Row},
|
||||
share::{LanOptions, ShareView},
|
||||
};
|
||||
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod screen;
|
||||
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
tr("horizon.friends.title", TITLE)
|
||||
}
|
||||
|
||||
use screen::{self as friends_screen, Action, FriendsView};
|
||||
pub use screen::{TITLE, title};
|
||||
|
||||
pub(super) struct Sharing {
|
||||
struct Sharing {
|
||||
host: Host,
|
||||
/// Sharing opened the world to LAN, so stopping closes it again.
|
||||
opened_lan: bool,
|
||||
@@ -31,12 +45,112 @@ pub(super) struct Sharing {
|
||||
paths: (u32, u32),
|
||||
}
|
||||
|
||||
/// The world shared, if one is. The game has one window player who can share (the engine
|
||||
/// calls the hooks for it only), so one is all there is.
|
||||
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
|
||||
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// What the page left for the next [`Hooks::share`] tick, which has the game's view.
|
||||
#[derive(Default)]
|
||||
struct PageState {
|
||||
/// Chat lines (new codes, the relay stopping), said in order.
|
||||
chat: Vec<String>,
|
||||
/// A button pressed on the page.
|
||||
pressed: Option<Action>,
|
||||
/// The page was drawn since the last tick: its guests are read again.
|
||||
open: bool,
|
||||
/// The other players, as the last tick while the page was open read them.
|
||||
guests: Vec<String>,
|
||||
/// The action of each row the page last showed.
|
||||
acts: Vec<Option<Action>>,
|
||||
}
|
||||
static PAGE_STATE: Mutex<PageState> = Mutex::new(PageState {
|
||||
chat: Vec::new(),
|
||||
pressed: None,
|
||||
open: false,
|
||||
guests: Vec::new(),
|
||||
acts: Vec::new(),
|
||||
});
|
||||
/// Whether [`PAGE_STATE`] has something for the tick.
|
||||
static WORK: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn page() -> MutexGuard<'static, PageState> {
|
||||
PAGE_STATE.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// A button of the page.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Action {
|
||||
Share,
|
||||
Copy,
|
||||
Stop,
|
||||
/// Open the troubleshooting page.
|
||||
Help,
|
||||
}
|
||||
|
||||
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
|
||||
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
|
||||
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
|
||||
fn take() -> Option<Sharing> {
|
||||
let taken = sharing().take();
|
||||
ON.store(false, Ordering::Relaxed);
|
||||
taken
|
||||
}
|
||||
|
||||
/// Registered by the engine's entry while this module is part of it.
|
||||
pub struct Friends;
|
||||
pub static HOOKS: Friends = Friends;
|
||||
|
||||
impl Hooks for Friends {
|
||||
/// What the page left for the game: chat lines, a button's press, the guests while it is
|
||||
/// open. With nothing left, one relaxed load.
|
||||
fn share(&self, world: &mut dyn ShareView) {
|
||||
if !WORK.swap(false, Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let (lines, pressed, open) = {
|
||||
let mut page = page();
|
||||
let open = std::mem::take(&mut page.open);
|
||||
(std::mem::take(&mut page.chat), page.pressed.take(), open)
|
||||
};
|
||||
for line in lines {
|
||||
world.chat(&line);
|
||||
}
|
||||
if let Some(action) = pressed {
|
||||
press(action, world);
|
||||
}
|
||||
if open {
|
||||
let mut guests = Vec::new();
|
||||
world.players(&mut |name| guests.push(name.to_owned()));
|
||||
page().guests = guests;
|
||||
}
|
||||
}
|
||||
|
||||
/// The LAN server is closing: the code goes with it.
|
||||
fn lan_closed(&self) {
|
||||
if ON.load(Ordering::Relaxed)
|
||||
&& let Some(mut sharing) = take()
|
||||
{
|
||||
sharing.host.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
"friends host share the open world behind an invite code",
|
||||
"friends stop stop sharing",
|
||||
"friends status the code, how friends reach this host, the guests",
|
||||
];
|
||||
|
||||
/// A string in the player's language, or the English one.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
horizon_hooks::tr(key).unwrap_or_else(|| english.to_owned())
|
||||
}
|
||||
|
||||
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
|
||||
fn stun_servers() -> Vec<String> {
|
||||
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
|
||||
@@ -53,101 +167,98 @@ fn env_flag(name: &str, default: bool) -> bool {
|
||||
std::env::var(name).map_or(default, |v| v != "0")
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Start sharing the open world.
|
||||
pub(super) fn friends_start(&mut self) -> Result<()> {
|
||||
if self.friends.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// Single player on its server thread shares that server, as Open to
|
||||
// LAN publishes it; another server can't be shared.
|
||||
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
|
||||
return Err("open a singleplayer world first".into());
|
||||
}
|
||||
let opened_lan = self.lan_port().is_none();
|
||||
if opened_lan {
|
||||
// ShareToLanScreen's defaults, as the world options screen offers them.
|
||||
self.lan_open(LanSettings {
|
||||
port: horizon_client::lan::available_port(),
|
||||
guest_commands: false,
|
||||
force_game_mode: true,
|
||||
})?;
|
||||
}
|
||||
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
|
||||
let host = match Host::start(HostConfig {
|
||||
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
game: GAME_VERSION.to_owned(),
|
||||
relay: horizon_client::net::friends_relay_setting(),
|
||||
path: PathMode::from_env(),
|
||||
port: std::env::var("HORIZON_FRIENDS_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
reach: reach::Options {
|
||||
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
|
||||
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
|
||||
stun: stun_servers(),
|
||||
// Both ends on one machine (tests).
|
||||
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
|
||||
},
|
||||
}) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
if opened_lan {
|
||||
self.lan_close(true);
|
||||
}
|
||||
return Err(e.into());
|
||||
/// Start sharing the open world.
|
||||
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
|
||||
if ON.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
if !world.can_share() {
|
||||
return Err("open a singleplayer world first".into());
|
||||
}
|
||||
let (port, opened_lan) = match world.lan_port() {
|
||||
Some(port) => (port, false),
|
||||
// ShareToLanScreen's defaults, as the world options screen offers them.
|
||||
None => (world.open_lan(LanOptions::default())?, true),
|
||||
};
|
||||
let host = Host::start(HostConfig {
|
||||
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
game: GAME_VERSION.to_owned(),
|
||||
relay: horizon_client::net::friends_relay_setting(),
|
||||
path: PathMode::from_env(),
|
||||
port: std::env::var("HORIZON_FRIENDS_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
reach: reach::Options {
|
||||
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
|
||||
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
|
||||
stun: stun_servers(),
|
||||
// Both ends on one machine (tests).
|
||||
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
|
||||
},
|
||||
});
|
||||
let host = match host {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
if opened_lan {
|
||||
world.close_lan();
|
||||
}
|
||||
};
|
||||
self.friends = Some(Sharing {
|
||||
host,
|
||||
opened_lan,
|
||||
code: None,
|
||||
wrong: 0,
|
||||
notice: None,
|
||||
copied: None,
|
||||
paths: (0, 0),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
*sharing() = Some(Sharing {
|
||||
host,
|
||||
opened_lan,
|
||||
code: None,
|
||||
wrong: 0,
|
||||
notice: None,
|
||||
copied: None,
|
||||
paths: (0, 0),
|
||||
});
|
||||
ON.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop sharing: the code ends (direct port closed, router mapping
|
||||
/// deleted); the world closes to LAN again if sharing opened it.
|
||||
pub(super) fn friends_stop(&mut self) {
|
||||
let Some(mut sharing) = self.friends.take() else {
|
||||
/// Stop sharing: the code ends (direct port closed, router mapping
|
||||
/// deleted); the world closes to LAN again if sharing opened it.
|
||||
fn stop(world: &mut dyn ShareView) {
|
||||
if !world.can_share() {
|
||||
return;
|
||||
}
|
||||
let Some(mut sharing) = take() else {
|
||||
return;
|
||||
};
|
||||
sharing.host.stop();
|
||||
if sharing.opened_lan {
|
||||
world.close_lan();
|
||||
} else {
|
||||
let text = tr(
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
);
|
||||
world.chat(&text);
|
||||
}
|
||||
}
|
||||
|
||||
/// New codes and sessions, while the page is drawn and when `friends status` asks. Its chat
|
||||
/// lines wait for the next tick ([`Hooks::share`]), or for [`say`].
|
||||
fn poll() {
|
||||
if !ON.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut announce = false;
|
||||
let mut stopped = None;
|
||||
{
|
||||
let mut guard = sharing();
|
||||
let Some(sharing) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
sharing.host.stop();
|
||||
if sharing.opened_lan {
|
||||
self.lan_close(true);
|
||||
} else {
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The LAN server is closing: the code goes with it.
|
||||
pub(super) fn friends_lan_closed(&mut self) {
|
||||
if let Some(mut sharing) = self.friends.take() {
|
||||
sharing.host.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// New codes and sessions, once a tick and while the pause menu is drawn.
|
||||
pub(super) fn tick_friends(&mut self) {
|
||||
let Some(sharing) = self.friends.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut announce = false;
|
||||
let mut stopped = None;
|
||||
for event in sharing.host.poll() {
|
||||
match event {
|
||||
HostEvent::Code(code) => {
|
||||
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
|
||||
sharing.notice = Some(lang::get_or(
|
||||
sharing.notice = Some(tr(
|
||||
"horizon.friends.short_code_changed",
|
||||
"Someone kept trying a wrong short code, so it changed.",
|
||||
));
|
||||
@@ -158,7 +269,7 @@ impl Game<'_> {
|
||||
}
|
||||
HostEvent::WrongCode { count } => {
|
||||
sharing.wrong = count;
|
||||
sharing.notice = Some(lang::get_or(
|
||||
sharing.notice = Some(tr(
|
||||
"horizon.friends.wrong_code",
|
||||
"Someone tried a wrong code.",
|
||||
));
|
||||
@@ -167,7 +278,7 @@ impl Game<'_> {
|
||||
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
|
||||
HostEvent::Disconnected { .. } => {}
|
||||
HostEvent::RelayStopped => {
|
||||
let text = lang::get_or(
|
||||
let text = tr(
|
||||
"horizon.friends.relay_stopped",
|
||||
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
|
||||
);
|
||||
@@ -176,164 +287,484 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(text) = stopped {
|
||||
self.system_message(Text::literal(text));
|
||||
}
|
||||
if announce {
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut page = page();
|
||||
page.chat.extend(stopped);
|
||||
if announce {
|
||||
page.chat.push(tr(
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
));
|
||||
}
|
||||
if !page.chat.is_empty() {
|
||||
WORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn friends_copy(&mut self) {
|
||||
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
|
||||
return;
|
||||
};
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&code);
|
||||
if let Some(s) = self.friends.as_mut() {
|
||||
s.copied = Some(Instant::now());
|
||||
}
|
||||
/// The chat lines waiting, said now.
|
||||
fn say(world: &mut dyn ShareView) {
|
||||
let lines = std::mem::take(&mut page().chat);
|
||||
for line in lines {
|
||||
world.chat(&line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Play with Friends. The world is paused under it in singleplayer, so
|
||||
/// new codes and sessions are read while it is drawn.
|
||||
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
|
||||
self.tick_friends();
|
||||
let view = self.friends_view();
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
|
||||
});
|
||||
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
|
||||
}
|
||||
pub(super) fn friends_screen_click(&mut self) {
|
||||
let view = self.friends_view();
|
||||
let Some(action) =
|
||||
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = self.friends_start() {
|
||||
self.system_message(Text::literal(
|
||||
lang::get_or(
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e.to_string(), 1),
|
||||
));
|
||||
}
|
||||
/// A button of the page, on the tick after its press.
|
||||
fn press(action: Action, world: &mut dyn ShareView) {
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = start(world) {
|
||||
let text = tr(
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e, 1);
|
||||
world.chat(&text);
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
Action::Stop => self.friends_stop(),
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => {
|
||||
if let Some(url) = &view.help {
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
}
|
||||
Action::Done => self.screen = super::Screen::Paused,
|
||||
}
|
||||
Action::Copy => copy(world),
|
||||
Action::Stop => stop(world),
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Help => world.open_url(reach::HELP_URL),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pause menu offers Play with Friends (a singleplayer world).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.friends_view().shown
|
||||
fn copy(world: &mut dyn ShareView) {
|
||||
if !world.can_share() {
|
||||
return;
|
||||
}
|
||||
|
||||
/// What Play with Friends shows.
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
let guests = match &self.lan {
|
||||
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
|
||||
// On the server thread: the other players the server lists.
|
||||
None if self.integrated.is_some() => {
|
||||
let me = self.options.player_name();
|
||||
let mut names = self.remote_online_names().unwrap_or_default();
|
||||
names.retain(|n| *n != me);
|
||||
names
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let Some(s) = &self.friends else {
|
||||
return FriendsView {
|
||||
shown: self.remote.is_none() || self.integrated.is_some(),
|
||||
..FriendsView::default()
|
||||
};
|
||||
};
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
lang::get_or(
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
if let Some(short) = &state.short {
|
||||
status.push_str(&format!(" Short code for the relay: {short}."));
|
||||
}
|
||||
if let Some(problem) = &state.relay_problem {
|
||||
status.push(' ');
|
||||
status.push_str(problem);
|
||||
}
|
||||
let reachable = matches!(
|
||||
state.verdict,
|
||||
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
|
||||
) || state.short.is_some();
|
||||
FriendsView {
|
||||
shown: true,
|
||||
sharing: true,
|
||||
code: s.code.clone(),
|
||||
status,
|
||||
reachable,
|
||||
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
|
||||
guests,
|
||||
notice: s.notice.clone(),
|
||||
copied: s
|
||||
.copied
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
|
||||
}
|
||||
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
|
||||
return;
|
||||
};
|
||||
world.copy(&code);
|
||||
if let Some(s) = sharing().as_mut() {
|
||||
s.copied = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
use serde_json::json;
|
||||
if words[0] != "friends" {
|
||||
return Ok(None);
|
||||
}
|
||||
match words.get(1).copied() {
|
||||
Some("host") => {
|
||||
self.friends_start()?;
|
||||
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
|
||||
}
|
||||
Some("stop") => {
|
||||
self.friends_stop();
|
||||
Ok(Some(json!({ "ok": true })))
|
||||
}
|
||||
Some("status") => {
|
||||
self.tick_friends();
|
||||
let view = self.friends_view();
|
||||
let state = self.friends.as_ref().map(|s| s.host.state());
|
||||
Ok(Some(json!({
|
||||
"sharing": view.sharing,
|
||||
"code": view.code,
|
||||
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
|
||||
"summary": view.status,
|
||||
"reachable": view.reachable,
|
||||
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
|
||||
"direct_port": state.as_ref().map(|s| s.port),
|
||||
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
|
||||
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
|
||||
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
|
||||
"guests": view.guests,
|
||||
"lan_port": self.lan_port(),
|
||||
})))
|
||||
}
|
||||
_ => Err("friends host|stop|status".into()),
|
||||
/// What Play with Friends shows.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct View {
|
||||
sharing: bool,
|
||||
code: Option<String>,
|
||||
/// Who can join and what to do when friends on the internet can't (or,
|
||||
/// before the code, what the game is doing).
|
||||
status: String,
|
||||
/// Friends on the internet can reach the host.
|
||||
reachable: bool,
|
||||
/// The players in the world besides the host.
|
||||
guests: Vec<String>,
|
||||
notice: Option<String>,
|
||||
/// The code was just copied.
|
||||
copied: bool,
|
||||
}
|
||||
|
||||
/// What Play with Friends shows; `guests` is asked only while sharing.
|
||||
fn view(guests: impl FnOnce() -> Vec<String>) -> View {
|
||||
let guard = sharing();
|
||||
let Some(s) = guard.as_ref() else {
|
||||
return View::default();
|
||||
};
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
tr(
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
if let Some(short) = &state.short {
|
||||
status.push_str(&format!(" Short code for the relay: {short}."));
|
||||
}
|
||||
if let Some(problem) = &state.relay_problem {
|
||||
status.push(' ');
|
||||
status.push_str(problem);
|
||||
}
|
||||
let reachable = matches!(
|
||||
state.verdict,
|
||||
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
|
||||
) || state.short.is_some();
|
||||
View {
|
||||
sharing: true,
|
||||
code: s.code.clone(),
|
||||
status,
|
||||
reachable,
|
||||
guests: guests(),
|
||||
notice: s.notice.clone(),
|
||||
copied: s
|
||||
.copied
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's rows for `view`, each with the button's action: the layout today's Friends
|
||||
/// screen had (the engine draws it, `game/page_screen.rs`).
|
||||
fn rows(view: &View, rows: &mut Vec<Row>, acts: &mut Vec<Option<Action>>) {
|
||||
let mut row = |row: Row, act: Option<Action>| {
|
||||
rows.push(row);
|
||||
acts.push(act);
|
||||
};
|
||||
let hint = || {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
};
|
||||
if !view.sharing {
|
||||
row(
|
||||
Row::text(tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
)),
|
||||
None,
|
||||
);
|
||||
row(Row::Space(4), None);
|
||||
row(Row::text(hint()).quiet(), None);
|
||||
row(Row::Space(8), None);
|
||||
row(
|
||||
Row::button("horizon.friends.start_sharing"),
|
||||
Some(Action::Share),
|
||||
);
|
||||
return;
|
||||
}
|
||||
row(
|
||||
Row::text(tr("horizon.multiplayer.invite_code", "Invite Code")).quiet(),
|
||||
None,
|
||||
);
|
||||
row(Row::Space(2), None);
|
||||
match &view.code {
|
||||
Some(code) => row(Row::text(code.clone()).groups(), None),
|
||||
None => row(Row::text(view.status.clone()), None),
|
||||
}
|
||||
row(Row::Space(6), None);
|
||||
let copy = if view.copied {
|
||||
"horizon.friends.copied"
|
||||
} else {
|
||||
"horizon.friends.copy_code"
|
||||
};
|
||||
row(
|
||||
Row::button(copy).half().active(view.code.is_some()),
|
||||
Some(Action::Copy),
|
||||
);
|
||||
row(
|
||||
Row::button("horizon.friends.stop_sharing").half(),
|
||||
Some(Action::Stop),
|
||||
);
|
||||
if view.code.is_some() {
|
||||
// Who can join: grey when friends anywhere can, white (it asks
|
||||
// for something) when only the LAN and VPNs can.
|
||||
let status = Row::text(view.status.clone());
|
||||
row(
|
||||
if view.reachable {
|
||||
status.quiet()
|
||||
} else {
|
||||
status
|
||||
},
|
||||
None,
|
||||
);
|
||||
row(Row::Space(4), None);
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
} else {
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen("%s", &view.guests.join(", "), 1)
|
||||
};
|
||||
row(Row::text(here).quiet(), None);
|
||||
if let Some(notice) = &view.notice {
|
||||
row(Row::text(notice.clone()), None);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
row(Row::text(hint()).quiet(), None);
|
||||
// Troubleshooting beside Done when friends on the internet can't reach the host.
|
||||
if !view.reachable && view.code.is_some() {
|
||||
row(
|
||||
Row::button("horizon.friends.troubleshooting").footer(),
|
||||
Some(Action::Help),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Play with Friends' page: what the pause menu's Friends icon opens. Its rows are read when
|
||||
/// it is drawn; a press waits for the next tick, which has the game's view.
|
||||
pub struct FriendsPage;
|
||||
pub static PAGE: FriendsPage = FriendsPage;
|
||||
|
||||
impl Page for FriendsPage {
|
||||
fn id(&self) -> &'static str {
|
||||
"horizon_friends.share"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"horizon.friends.title"
|
||||
}
|
||||
fn place(&self) -> Place {
|
||||
Place::PauseFriends
|
||||
}
|
||||
fn rows(&self, out: &mut Vec<Row>) {
|
||||
// The world is paused under the page in singleplayer, so new codes and sessions are
|
||||
// read while it is drawn.
|
||||
poll();
|
||||
let guests = std::mem::take(&mut page().guests);
|
||||
let view = view(|| guests);
|
||||
let mut acts = Vec::new();
|
||||
rows(&view, out, &mut acts);
|
||||
let mut page = page();
|
||||
page.guests = view.guests;
|
||||
page.acts = acts;
|
||||
page.open = true;
|
||||
WORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn act(&self, row: usize, act: Act) {
|
||||
let mut page = page();
|
||||
if act == Act::Press
|
||||
&& let Some(Some(action)) = page.acts.get(row).copied()
|
||||
{
|
||||
page.pressed = Some(action);
|
||||
WORK.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends host|stop|status`.
|
||||
fn control(
|
||||
word: Option<&str>,
|
||||
world: &mut dyn ShareView,
|
||||
) -> std::result::Result<serde_json::Value, String> {
|
||||
use serde_json::json;
|
||||
match word {
|
||||
Some("host") => {
|
||||
start(world)?;
|
||||
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
|
||||
}
|
||||
Some("stop") => {
|
||||
stop(world);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
Some("status") => {
|
||||
poll();
|
||||
say(world);
|
||||
let view = if world.can_share() {
|
||||
view(|| {
|
||||
let mut guests = Vec::new();
|
||||
world.players(&mut |name| guests.push(name.to_owned()));
|
||||
guests
|
||||
})
|
||||
} else {
|
||||
View::default()
|
||||
};
|
||||
let guard = sharing();
|
||||
let sharing = guard.as_ref().filter(|_| view.sharing);
|
||||
let state = sharing.map(|s| s.host.state());
|
||||
Ok(json!({
|
||||
"sharing": view.sharing,
|
||||
"code": view.code,
|
||||
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
|
||||
"summary": view.status,
|
||||
"reachable": view.reachable,
|
||||
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
|
||||
"direct_port": state.as_ref().map(|s| s.port),
|
||||
"sessions": sharing.map_or(0, |s| s.host.sessions()),
|
||||
"direct": sharing.map_or(0, |s| s.paths.0),
|
||||
"relayed": sharing.map_or(0, |s| s.paths.1),
|
||||
"guests": view.guests,
|
||||
"lan_port": world.lan_port(),
|
||||
}))
|
||||
}
|
||||
_ => Err("friends host|stop|status".into()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
if words[0] != "friends" {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(control(
|
||||
words.get(1).copied(),
|
||||
&mut self.share_view(),
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A world as the engine shows it, recording what the crate did to it.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
can_share: bool,
|
||||
lan: Option<u16>,
|
||||
refuse_lan: bool,
|
||||
did: Vec<String>,
|
||||
}
|
||||
|
||||
impl ShareView for Fake {
|
||||
fn can_share(&self) -> bool {
|
||||
self.can_share
|
||||
}
|
||||
fn lan_port(&self) -> Option<u16> {
|
||||
self.lan
|
||||
}
|
||||
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
|
||||
self.did.push(format!("open_lan {options:?}"));
|
||||
if self.refuse_lan {
|
||||
return Err("no free port".into());
|
||||
}
|
||||
self.lan = Some(25565);
|
||||
Ok(25565)
|
||||
}
|
||||
fn close_lan(&mut self) {
|
||||
self.did.push("close_lan".into());
|
||||
self.lan = None;
|
||||
}
|
||||
fn players(&self, each: &mut dyn FnMut(&str)) {
|
||||
each("Alex");
|
||||
}
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.did.push(format!("chat {text}"));
|
||||
}
|
||||
fn copy(&mut self, text: &str) {
|
||||
self.did.push(format!("copy {text}"));
|
||||
}
|
||||
fn open_url(&mut self, url: &str) {
|
||||
self.did.push(format!("open_url {url}"));
|
||||
}
|
||||
fn tr(&self, _key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_that_cannot_be_shared_is_left_alone() {
|
||||
// Another server, or a splitscreen guest in P1's world.
|
||||
let mut guest = Fake {
|
||||
lan: Some(25565),
|
||||
..Fake::default()
|
||||
};
|
||||
assert_eq!(
|
||||
control(Some("host"), &mut guest).unwrap_err(),
|
||||
"open a singleplayer world first"
|
||||
);
|
||||
stop(&mut guest);
|
||||
copy(&mut guest);
|
||||
assert!(guest.did.is_empty(), "{:?}", guest.did);
|
||||
assert!(!ON.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
|
||||
let mut world = Fake {
|
||||
can_share: true,
|
||||
refuse_lan: true,
|
||||
..Fake::default()
|
||||
};
|
||||
assert_eq!(start(&mut world).unwrap_err(), "no free port");
|
||||
assert_eq!(
|
||||
world.did,
|
||||
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
|
||||
);
|
||||
// Offered, not shared: the guests aren't asked for.
|
||||
let view = view(|| unreachable!("the guests of a world not shared"));
|
||||
assert!(!view.sharing && view.guests.is_empty());
|
||||
assert!(!ON.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_shows_todays_screen_and_a_press_runs_on_the_next_tick() {
|
||||
// Sharing, with a code friends on the internet can't reach yet.
|
||||
let shared = View {
|
||||
sharing: true,
|
||||
code: Some("K7QM2-AAAAA".into()),
|
||||
status: "Friends on your network can join.".into(),
|
||||
..View::default()
|
||||
};
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
rows(&shared, &mut out, &mut acts);
|
||||
let buttons: Vec<_> = out
|
||||
.iter()
|
||||
.zip(&acts)
|
||||
.filter_map(|(row, act)| match row {
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
half,
|
||||
footer,
|
||||
..
|
||||
} => Some((*label, *active, *half, *footer, *act)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
buttons,
|
||||
[
|
||||
(
|
||||
"horizon.friends.copy_code",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some(Action::Copy)
|
||||
),
|
||||
(
|
||||
"horizon.friends.stop_sharing",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some(Action::Stop)
|
||||
),
|
||||
(
|
||||
"horizon.friends.troubleshooting",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
Some(Action::Help)
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(out.iter().any(
|
||||
|row| matches!(row, Row::Text { text, groups: true, .. } if text == "K7QM2-AAAAA")
|
||||
));
|
||||
// Before the code there is nothing to copy.
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
rows(
|
||||
&View {
|
||||
code: None,
|
||||
..shared
|
||||
},
|
||||
&mut out,
|
||||
&mut acts,
|
||||
);
|
||||
assert!(out.iter().any(|row| matches!(
|
||||
row,
|
||||
Row::Button {
|
||||
label: "horizon.friends.copy_code",
|
||||
active: false,
|
||||
..
|
||||
}
|
||||
)));
|
||||
|
||||
// Not sharing: Start Sharing. Its press waits for the tick, which has the world.
|
||||
let (mut out, mut acts) = (Vec::new(), Vec::new());
|
||||
rows(&View::default(), &mut out, &mut acts);
|
||||
let share = acts.iter().position(|a| *a == Some(Action::Share)).unwrap();
|
||||
page().acts = acts;
|
||||
PAGE.act(share, Act::Press);
|
||||
let mut world = Fake {
|
||||
can_share: true,
|
||||
refuse_lan: true,
|
||||
..Fake::default()
|
||||
};
|
||||
HOOKS.share(&mut world);
|
||||
assert_eq!(
|
||||
world.did,
|
||||
[
|
||||
"open_lan LanOptions { guest_commands: false, force_game_mode: true }",
|
||||
"chat Couldn't share the world: no free port",
|
||||
]
|
||||
);
|
||||
// Pressed once, run once.
|
||||
HOOKS.share(&mut world);
|
||||
assert_eq!(world.did.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
//! Play with Friends: Horizon's own sharing of the open world behind an
|
||||
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
|
||||
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
|
||||
//! a screen to vanilla: the menu background, a centred title and text,
|
||||
//! vanilla's buttons and Done in the footer.
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
multiplayer::wrap,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
|
||||
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// A string of this screen: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
horizon_client::lang::get_or(key, english)
|
||||
}
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
tr("horizon.friends.title", TITLE)
|
||||
}
|
||||
/// What the screen shows (game/friends.rs fills it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FriendsView {
|
||||
/// A singleplayer world (not a server): sharing is offered.
|
||||
pub shown: bool,
|
||||
pub sharing: bool,
|
||||
pub code: Option<String>,
|
||||
/// Who can join and what to do when friends on the internet can't (or,
|
||||
/// before the code, what the game is doing).
|
||||
pub status: String,
|
||||
/// Friends on the internet can reach the host.
|
||||
pub reachable: bool,
|
||||
/// Where the troubleshooting is, when they can't.
|
||||
pub help: Option<String>,
|
||||
/// The players in the world besides the host.
|
||||
pub guests: Vec<String>,
|
||||
pub notice: Option<String>,
|
||||
/// The code was just copied.
|
||||
pub copied: bool,
|
||||
}
|
||||
|
||||
/// A button's action.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
Share,
|
||||
Copy,
|
||||
Stop,
|
||||
/// Open the troubleshooting page.
|
||||
Help,
|
||||
Done,
|
||||
}
|
||||
|
||||
fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
fn hint() -> String {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
}
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: String,
|
||||
action: Action,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// The centred lines (text, colour, y) and the buttons.
|
||||
fn layout(
|
||||
atlas: &HudAtlas,
|
||||
(w, h): (f32, f32),
|
||||
view: &FriendsView,
|
||||
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
|
||||
let width = (w - 40.0).min(310.0);
|
||||
let cx = (w / 2.0).floor();
|
||||
let mut lines = Vec::new();
|
||||
let mut y = 40.0;
|
||||
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
|
||||
for line in wrap(text, width, atlas) {
|
||||
lines.push((line, color, *y));
|
||||
*y += 10.0;
|
||||
}
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
if view.sharing {
|
||||
lines.push((
|
||||
tr("horizon.multiplayer.invite_code", "Invite Code"),
|
||||
grey(),
|
||||
y,
|
||||
));
|
||||
y += 12.0;
|
||||
match &view.code {
|
||||
Some(code) => {
|
||||
for line in code_lines(atlas, code, width) {
|
||||
lines.push((line, WHITE, y));
|
||||
y += 10.0;
|
||||
}
|
||||
}
|
||||
None => add(&mut lines, &view.status, WHITE, &mut y),
|
||||
}
|
||||
y += 6.0;
|
||||
buttons.push(Button {
|
||||
x: cx - 154.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: if view.copied {
|
||||
tr("horizon.friends.copied", "Copied")
|
||||
} else {
|
||||
tr("horizon.friends.copy_code", "Copy Code")
|
||||
},
|
||||
action: Action::Copy,
|
||||
active: view.code.is_some(),
|
||||
});
|
||||
buttons.push(Button {
|
||||
x: cx + 4.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
|
||||
action: Action::Stop,
|
||||
active: true,
|
||||
});
|
||||
y += 30.0;
|
||||
if view.code.is_some() {
|
||||
// Who can join: grey when friends anywhere can, white (it asks
|
||||
// for something) when only the LAN and VPNs can.
|
||||
let tint = if view.reachable { grey() } else { WHITE };
|
||||
add(&mut lines, &view.status, tint, &mut y);
|
||||
y += 4.0;
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
} else {
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen(
|
||||
"%s",
|
||||
&view.guests.join(", "),
|
||||
1,
|
||||
)
|
||||
};
|
||||
add(&mut lines, &here, grey(), &mut y);
|
||||
if let Some(notice) = &view.notice {
|
||||
add(&mut lines, notice, WHITE, &mut y);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
let hint = hint();
|
||||
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, &hint, grey(), &mut y);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
&mut lines,
|
||||
&tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
),
|
||||
WHITE,
|
||||
&mut y,
|
||||
);
|
||||
y += 4.0;
|
||||
add(&mut lines, &hint(), grey(), &mut y);
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: y + 8.0,
|
||||
w: 200.0,
|
||||
label: tr("horizon.friends.start_sharing", "Start Sharing"),
|
||||
action: Action::Share,
|
||||
active: view.shown,
|
||||
});
|
||||
}
|
||||
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
|
||||
// friends on the internet can't reach the host.
|
||||
if view.sharing && view.help.is_some() {
|
||||
buttons.push(Button {
|
||||
x: cx - 154.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
|
||||
action: Action::Help,
|
||||
active: true,
|
||||
});
|
||||
buttons.push(Button {
|
||||
x: cx + 4.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
} else {
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: h - 27.0,
|
||||
w: 200.0,
|
||||
label: "gui.done".to_owned(),
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
(lines, buttons)
|
||||
}
|
||||
/// The code's groups packed into lines of at most `width`.
|
||||
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for group in code.split('-') {
|
||||
match lines.last_mut() {
|
||||
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
|
||||
l.push('-');
|
||||
l.push_str(group);
|
||||
}
|
||||
_ => lines.push(group.to_owned()),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
fn label(key: &str) -> Text {
|
||||
if key.starts_with("gui.") {
|
||||
Text::translate(key, &[])
|
||||
} else {
|
||||
Text::literal(key)
|
||||
}
|
||||
}
|
||||
pub fn draw(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
cursor: (f32, f32),
|
||||
view: &FriendsView,
|
||||
in_world: bool,
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(title());
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), view);
|
||||
for (line, color, y) in lines {
|
||||
let lw = HudBatch::text_width(atlas, &line);
|
||||
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
|
||||
}
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
if matches!(b.action, Action::Done) {
|
||||
horizon_client::widgets::declare_action(
|
||||
horizon_client::widgets::Action::Done,
|
||||
[b.x, b.y, b.w, 20.0],
|
||||
b.active,
|
||||
);
|
||||
}
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
b.x,
|
||||
b.y,
|
||||
b.w,
|
||||
20.0,
|
||||
&label(&b.label),
|
||||
b.active,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The button under a click, if it is active.
|
||||
pub fn click(
|
||||
atlas: &HudAtlas,
|
||||
size: (f32, f32),
|
||||
cursor: (f32, f32),
|
||||
view: &FriendsView,
|
||||
) -> Option<Action> {
|
||||
layout(atlas, size, view)
|
||||
.1
|
||||
.into_iter()
|
||||
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
|
||||
.map(|b| b.action)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn share_then_copy_and_stop() {
|
||||
let atlas = atlas();
|
||||
let size = (427.0, 240.0);
|
||||
let idle = FriendsView {
|
||||
shown: true,
|
||||
..FriendsView::default()
|
||||
};
|
||||
let (_, buttons) = layout(&atlas, size, &idle);
|
||||
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
|
||||
assert_eq!(
|
||||
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
|
||||
Some(Action::Share)
|
||||
);
|
||||
let sharing = FriendsView {
|
||||
shown: true,
|
||||
sharing: true,
|
||||
status: "Finding how friends can reach you…".into(),
|
||||
..FriendsView::default()
|
||||
};
|
||||
let (_, buttons) = layout(&atlas, size, &sharing);
|
||||
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
|
||||
assert!(!copy.active, "nothing to copy before the code");
|
||||
let with_code = FriendsView {
|
||||
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
|
||||
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
|
||||
help: Some("https://example.invalid/help".into()),
|
||||
..sharing
|
||||
};
|
||||
let (lines, buttons) = layout(&atlas, size, &with_code);
|
||||
assert!(
|
||||
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
|
||||
"the text stays above the footer"
|
||||
);
|
||||
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
|
||||
let b = buttons.iter().find(|b| b.action == action).unwrap();
|
||||
assert_eq!(
|
||||
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
|
||||
Some(action)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_offers_no_sharing() {
|
||||
let atlas = atlas();
|
||||
let view = FriendsView::default();
|
||||
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
|
||||
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
|
||||
assert!(!share.active);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
|
||||
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
|
||||
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, hud::HudBatch};
|
||||
use super::Game;
|
||||
use horizon_client::Result;
|
||||
|
||||
pub const HELP: &[&str] = &[];
|
||||
|
||||
@@ -13,21 +13,6 @@ pub fn title() -> String {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn friends_lan_closed(&mut self) {}
|
||||
|
||||
pub(super) fn tick_friends(&mut self) {}
|
||||
|
||||
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
|
||||
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
|
||||
self.screen = Screen::Paused;
|
||||
}
|
||||
|
||||
pub(super) fn friends_screen_click(&mut self) {}
|
||||
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
if words[0] != "friends" {
|
||||
|
||||
@@ -499,7 +499,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.next_entity += 1;
|
||||
self.orbs.push(ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -73,9 +73,6 @@ impl Game<'_> {
|
||||
_ if let Some(i) = self.res.items().instrument(stack) => i.use_ticks(),
|
||||
_ if let Some(consumable) = self.consumable(stack) => consumable.ticks(),
|
||||
_ if let Some(ticks) = patched_ticks => ticks,
|
||||
_ if let Some(seconds) = horizon_client::item_sync::consumable_seconds(stack) => {
|
||||
(seconds * 20.0) as i32
|
||||
}
|
||||
_ => match info {
|
||||
Some(i) if let Some(c) = &i.consumable => c.ticks(),
|
||||
_ if patched_blocks => 72_000,
|
||||
|
||||
@@ -1490,7 +1490,7 @@ impl Game<'_> {
|
||||
None => self.unpublish(),
|
||||
};
|
||||
if closed {
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
}
|
||||
if closed && announce && self.integrated.is_some() {
|
||||
self.system_message(Text::translate(
|
||||
@@ -1500,7 +1500,7 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
self.native_players_leaving();
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
|
||||
@@ -3477,7 +3477,13 @@ impl Game<'_> {
|
||||
if host.hub.guests[gi].spectator() {
|
||||
return;
|
||||
}
|
||||
if self.guest_attack_stops(host, gi, id) {
|
||||
// handleAttack: a target inside the world border, then
|
||||
// `isWithinAttackRange(mainHandItem, box, 3.0)` before anything else, players,
|
||||
// mobs, objects and fireballs alike.
|
||||
if let Some(target) = self.guest_target_box(host, gi, id)
|
||||
&& (!self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Native mods, where Player.attack starts: a canceled blow does
|
||||
@@ -6214,7 +6220,6 @@ fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>
|
||||
[range(at[0]), range(at[2])]
|
||||
}
|
||||
|
||||
mod attack_checks;
|
||||
mod dig_stop;
|
||||
use dig_stop::{StopDig, stop_dig};
|
||||
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
//! `ServerGamePacketListenerImpl.handleAttack`'s checks between the target's lookup and
|
||||
//! `Player.attack`: the world border, the attack range, and the targets no player may attack.
|
||||
use super::*;
|
||||
use horizon_client::projectiles::ProjectileKind;
|
||||
use horizon_protocol::{packets::configuration::UpdateTags, text::translatable};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether a guest's attack on entity `id` ends before `Player.attack` (handleAttack).
|
||||
/// The guest who attacks an `ItemEntity`, an `ExperienceOrb`, themselves or an
|
||||
/// `AbstractArrow` that is not `isAttackable` is disconnected with
|
||||
/// `multiplayer.disconnect.invalid_entity_attacked`.
|
||||
pub(super) fn guest_attack_stops(&mut self, host: &mut LanHost, gi: usize, id: i32) -> bool {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
// handleAttack: a target inside the world border, then
|
||||
// `isWithinAttackRange(mainHandItem, box, 3.0)` before anything else, players,
|
||||
// mobs, objects and fireballs alike.
|
||||
if let Some(target) = self.guest_target_box(host, gi, id)
|
||||
&& (!self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some(target) = self.refused_target(host, gi, id) else {
|
||||
return false;
|
||||
};
|
||||
// The same checks for these targets, then `PIERCING_WEAPON` in the main hand
|
||||
// returns before the kick; handleAttack ignores a player whose client has not
|
||||
// loaded (hasClientLoaded) before all of them.
|
||||
if !host.hub.guests[gi].client_loaded()
|
||||
|| !self.inside_border(&target)
|
||||
|| self.as_guest(host, conn, |g| {
|
||||
g.within_attack_range(&target) && !g.holds_piercing_weapon()
|
||||
}) != Some(true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let name = host.hub.guests[gi].name.clone();
|
||||
host.hub.kick(
|
||||
conn,
|
||||
translatable("multiplayer.disconnect.invalid_entity_attacked", &[]),
|
||||
);
|
||||
log::warn!("Player {name} tried to attack an invalid entity");
|
||||
true
|
||||
}
|
||||
|
||||
/// The box of entity `id` when handleAttack refuses it as a target: an `ItemEntity`, an
|
||||
/// `ExperienceOrb`, the attacking player, or an `AbstractArrow` (an arrow, a spectral
|
||||
/// arrow, a trident) that is not `isAttackable`. Only for a guest in the level in place,
|
||||
/// whose entities these are.
|
||||
fn refused_target(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
|
||||
let g = &host.hub.guests[gi];
|
||||
if !host.hub.here(g) {
|
||||
return None;
|
||||
}
|
||||
if g.entity_id == id {
|
||||
return Some(g.bounds());
|
||||
}
|
||||
let id = u32::try_from(id).ok().filter(|&id| id != 0)?;
|
||||
if let Some(item) = self.dropped.iter().find(|i| i.id == id) {
|
||||
return Some(item.bounds());
|
||||
}
|
||||
if let Some(orb) = self.orbs.iter().find(|o| o.id == id) {
|
||||
return Some(orb.bounds());
|
||||
}
|
||||
let sync = host.hub.lan.shared.sync();
|
||||
let refused =
|
||||
|kind: &str| !arrow_attackable(&sync.tags, sync.id("minecraft:entity_type", kind));
|
||||
if let Some(p) = self.creatures.projectiles.iter().find(|p| {
|
||||
p.id == id
|
||||
&& matches!(
|
||||
p.kind,
|
||||
ProjectileKind::Arrow { .. } | ProjectileKind::Trident
|
||||
)
|
||||
}) {
|
||||
return refused(p.entity_type()).then(|| p.bounds());
|
||||
}
|
||||
// A thrown trident: `EntityType.TRIDENT`, 0.5 x 0.5.
|
||||
let t = self.tridents.iter().find(|t| t.id == id)?;
|
||||
let p = t.position;
|
||||
refused("minecraft:trident").then(|| {
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - 0.25, p.y, p.z - 0.25),
|
||||
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The main-hand stack has `minecraft:piercing_weapon` (a spear's).
|
||||
fn holds_piercing_weapon(&self) -> bool {
|
||||
self.held().is_some_and(|stack| {
|
||||
self.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components_view(&horizon_client::item_render::stack_view(stack))
|
||||
.contains_key("minecraft:piercing_weapon")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractArrow.isAttackable`: the arrow's type (`type_id`) is in
|
||||
/// `#minecraft:redirectable_projectile`, as the server sent its tags to the players, a world's
|
||||
/// data packs included. A type or a tag missing from the data counts as attackable, so no
|
||||
/// player is kicked over data the server does not have.
|
||||
fn arrow_attackable(tags: &UpdateTags, type_id: Option<i32>) -> bool {
|
||||
let Some(type_id) = type_id else {
|
||||
return true;
|
||||
};
|
||||
let tag = tags
|
||||
.registries
|
||||
.iter()
|
||||
.find(|r| r.registry == "minecraft:entity_type")
|
||||
.and_then(|r| {
|
||||
r.tags
|
||||
.iter()
|
||||
.find(|t| t.name == "minecraft:redirectable_projectile")
|
||||
});
|
||||
tag.is_none_or(|t| t.ids.contains(&type_id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_protocol::packets::configuration::{
|
||||
ClientInformation, RegistryTags, TagEntry, UpdateTags,
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
fn tags(redirectable: &[i32]) -> UpdateTags {
|
||||
UpdateTags {
|
||||
registries: vec![RegistryTags {
|
||||
registry: "minecraft:entity_type".into(),
|
||||
tags: vec![TagEntry {
|
||||
name: "minecraft:redirectable_projectile".into(),
|
||||
ids: redirectable.to_vec(),
|
||||
}],
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractArrow.isAttackable` reads the tag the players were sent: a data pack that
|
||||
/// puts arrows in `#minecraft:redirectable_projectile` makes them attackable, and a
|
||||
/// vanilla client then picks and attacks them, which must not kick.
|
||||
#[test]
|
||||
fn an_arrow_is_attackable_only_in_the_sent_redirectable_tag() {
|
||||
assert!(!arrow_attackable(&tags(&[30, 40]), Some(5)));
|
||||
assert!(arrow_attackable(&tags(&[5, 30, 40]), Some(5)));
|
||||
assert!(arrow_attackable(&tags(&[30]), None), "an unknown type");
|
||||
assert!(
|
||||
arrow_attackable(&UpdateTags { registries: vec![] }, Some(5)),
|
||||
"no entity type tags"
|
||||
);
|
||||
}
|
||||
|
||||
fn disconnects(rx: &mpsc::Receiver<Out>) -> Vec<horizon_world::nbt::Tag> {
|
||||
rx.try_iter()
|
||||
.filter_map(|out| match out {
|
||||
Out::Disconnect(reason) => Some(reason),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// handleAttack disconnects a player who attacks a dropped item, an experience orb,
|
||||
/// themselves, an arrow or a thrown trident (`invalid_entity_attacked`); a pig is
|
||||
/// attacked as before, and an item out of reach, or attacked with a spear in hand,
|
||||
/// ends the attack without a kick.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn attacking_an_item_an_orb_oneself_or_an_arrow_kicks_the_player() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::env::temp_dir().join(format!("horizon-attack-checks-unit-{}", std::process::id())),
|
||||
);
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap()
|
||||
.0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
chunk.set(x, 199, z, stone);
|
||||
}
|
||||
}
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game.lan_host(
|
||||
crate::game::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "attack-checks-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let summoned = |game: &mut Game<'_>, command: &str| {
|
||||
let before = game.next_entity;
|
||||
assert!(game.run_command(command), "{command}");
|
||||
game.lan_assign_ids();
|
||||
assert!(game.next_entity > before, "{command}");
|
||||
game.next_entity as i32
|
||||
};
|
||||
let pig = summoned(
|
||||
&mut game,
|
||||
"summon pig 6.5 200 4.5 {NoAI:1b,NoGravity:1b,PersistenceRequired:1b}",
|
||||
);
|
||||
let item = summoned(
|
||||
&mut game,
|
||||
"summon item 4.5 200 6 {NoGravity:1b,Item:{id:\"minecraft:stone\",count:1}}",
|
||||
);
|
||||
let far_item = summoned(
|
||||
&mut game,
|
||||
"summon item 4.5 200 12.5 {NoGravity:1b,Item:{id:\"minecraft:stone\",count:1}}",
|
||||
);
|
||||
let orb = summoned(
|
||||
&mut game,
|
||||
"summon experience_orb 3 200 4.5 {NoGravity:1b,Value:1s}",
|
||||
);
|
||||
let arrow = summoned(&mut game, "summon arrow 4.5 200.5 3 {NoGravity:1b}");
|
||||
let trident = summoned(&mut game, "summon trident 5.5 200.5 3 {NoGravity:1b}");
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 332,
|
||||
name: "AttackGuest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
game.next_entity as i32 + 1,
|
||||
[4.5, 200.0, 4.5],
|
||||
2,
|
||||
);
|
||||
game.next_entity += 1;
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.load_timeout = 0;
|
||||
let me = guest.entity_id;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
let attack = |game: &mut Game<'_>, host: &mut LanHost, id: i32| {
|
||||
game.lan_guest_packet(host, 2, S::Attack(play::Attack { id }));
|
||||
};
|
||||
let kicked = vec![translatable(
|
||||
"multiplayer.disconnect.invalid_entity_attacked",
|
||||
&[],
|
||||
)];
|
||||
|
||||
let hp = |g: &Game<'_>| g.mobs.iter().find(|m| m.id as i32 == pig).unwrap().health;
|
||||
let full = hp(&game);
|
||||
attack(&mut game, &mut host, pig);
|
||||
assert_eq!(disconnects(&rx), vec![], "a pig is a target");
|
||||
assert!(hp(&game) < full, "the pig is not hurt: {full} health left");
|
||||
attack(&mut game, &mut host, far_item);
|
||||
assert_eq!(disconnects(&rx), vec![], "an item out of reach");
|
||||
host.hub.guests[0].inventory[0] = Some(ItemStack::new("minecraft:iron_spear", 1));
|
||||
host.hub.guests[0].selected = 0;
|
||||
attack(&mut game, &mut host, item);
|
||||
assert_eq!(disconnects(&rx), vec![], "a spear in hand");
|
||||
host.hub.guests[0].inventory[0] = None;
|
||||
for (what, id) in [
|
||||
("an item", item),
|
||||
("an experience orb", orb),
|
||||
("themselves", me),
|
||||
("an arrow", arrow),
|
||||
("a thrown trident", trident),
|
||||
] {
|
||||
attack(&mut game, &mut host, id);
|
||||
assert_eq!(disconnects(&rx), kicked, "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,13 +163,8 @@ impl WorldOptions {
|
||||
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
|
||||
/// shows there too; a remote world has no such box.
|
||||
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
|
||||
if remote || !ui::hovered(cursor, port.x, port.y, port.width, port.height) {
|
||||
return None;
|
||||
}
|
||||
Some(match port_error(&port.value) {
|
||||
Some(key) => Text::translate(key, &[Text::literal("1024"), Text::literal("65535")]),
|
||||
None => Text::translate("lanServer.port", &[]),
|
||||
})
|
||||
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
|
||||
.then(|| Text::translate("lanServer.port", &[]))
|
||||
}
|
||||
fn snapshot(&self) -> Snapshot {
|
||||
Snapshot {
|
||||
@@ -561,18 +556,6 @@ impl Restrictions {
|
||||
}
|
||||
}
|
||||
|
||||
/// `WorldOptionsScreen.tryParsePort`'s invalid value: not a number in
|
||||
/// `PORT_LOWER_BOUND..=PORT_HIGHER_BOUND`. A number another process holds
|
||||
/// (`HttpUtil.isPortAvailable`) is not checked: the port map records that gap.
|
||||
fn port_error(value: &str) -> Option<&'static str> {
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match value.parse::<u32>() {
|
||||
Ok(port) if (1024..=65535).contains(&port) => None,
|
||||
_ => Some("lanServer.port.invalid"),
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn remote_world_options_permission(&mut self, permission: bool) {
|
||||
if let Some(o) = self.world_options.as_mut() {
|
||||
@@ -1036,8 +1019,7 @@ impl Game<'_> {
|
||||
}
|
||||
// The widgets are drawn; the port box's tooltip goes over them.
|
||||
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
|
||||
// Tooltip.splitTooltip: font.split(message, 170).
|
||||
ui::tooltip(batch, atlas, &atlas.font.split(&tip, 170.0), cursor);
|
||||
ui::tooltip(batch, atlas, &[tip], cursor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1936,29 +1918,11 @@ mod tests {
|
||||
}
|
||||
|
||||
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
|
||||
/// label; `setPortError` turns that into the reason a number is rejected.
|
||||
/// A remote world (`o.remote`) has no port box to hover.
|
||||
/// label; a remote world (`o.remote`) has no port box to hover.
|
||||
#[test]
|
||||
fn the_port_box_explains_itself_on_hover() {
|
||||
let mut port = EditBox::new(158.0, 100.0, 40.0, 20.0);
|
||||
let tip = |port: &EditBox| {
|
||||
WorldOptions::port_tooltip(port, (159.0, 101.0), false).map(|t| t.plain())
|
||||
};
|
||||
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"));
|
||||
// Empty takes the suggestion, a number in range is fine.
|
||||
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"));
|
||||
for bad in ["0", "80", "70000", "abc", "-1"] {
|
||||
port.set_value(bad);
|
||||
assert_eq!(
|
||||
tip(&port).as_deref(),
|
||||
Some("lanServer.port.invalid"),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
for good in ["1024", "25565", "65535"] {
|
||||
port.set_value(good);
|
||||
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"), "{good}");
|
||||
}
|
||||
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
|
||||
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
|
||||
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
//! Lectern placement and the `block_change` game event of a book going on or
|
||||
//! off (`LecternBlock.getStateForPlacement`, `LecternBlock.placeBook` and
|
||||
//! `LecternBlockEntity.onBookItemRemove`).
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::Game;
|
||||
use crate::game::{Hit, sculk::TEST_POSTED_EVENTS};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
};
|
||||
|
||||
/// A lectern at `pos`: the state's `has_book`, and the entity's book.
|
||||
fn lectern(game: &mut Game<'_>, pos: [i32; 3], has_book: bool, book: Option<ItemStack>) {
|
||||
let registry = game.pack.assets.registry;
|
||||
let default = registry.default_state("minecraft:lectern").unwrap();
|
||||
let mut state = registry.state(default).unwrap().clone();
|
||||
state
|
||||
.properties
|
||||
.insert("has_book".into(), has_book.to_string());
|
||||
let state = registry.id(&state).unwrap();
|
||||
game.world.set(pos[0], pos[1], pos[2], state).unwrap();
|
||||
game.world.block_entities.insert(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
BlockEntity::Lectern(Lectern { book, page: 0 }),
|
||||
);
|
||||
}
|
||||
|
||||
/// The lectern item `/give` hands out with a written book in its
|
||||
/// `block_entity_data`.
|
||||
fn lectern_item() -> ItemStack {
|
||||
let text = r#"{"minecraft:block_entity_data":{id:"minecraft:lectern",Book:{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":{title:"t",author:"a",pages:["p1","p2"]}}}}}"#;
|
||||
let components = horizon_world::nbt::parse_snbt(text).expect("snbt").0;
|
||||
let (damage, extra) = horizon_client::item_data::decode_components(Some(&components));
|
||||
ItemStack {
|
||||
damage,
|
||||
extra,
|
||||
..ItemStack::new("minecraft:lectern", 1)
|
||||
}
|
||||
}
|
||||
|
||||
fn click(pos: [i32; 3]) -> Hit {
|
||||
Hit {
|
||||
pos,
|
||||
face: [0, 1, 0],
|
||||
point: [f64::from(pos[0]) + 0.5, f64::from(pos[1]), 0.5],
|
||||
}
|
||||
}
|
||||
|
||||
/// The `has_book` of the lectern state at `pos`, and what a comparator beside
|
||||
/// it reads (`LecternBlock.getAnalogOutputSignal`).
|
||||
fn state_at(game: &Game<'_>, pos: [i32; 3]) -> (String, i32) {
|
||||
let registry = game.pack.assets.registry;
|
||||
let state = game.world.block(pos[0], pos[1], pos[2]);
|
||||
let has_book = registry
|
||||
.state(state)
|
||||
.unwrap()
|
||||
.properties
|
||||
.get("has_book")
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let comparator = super::lectern_signal(&game.world, registry, state, at).expect("a lectern");
|
||||
(has_book, comparator)
|
||||
}
|
||||
|
||||
/// `LecternBlock.getStateForPlacement`: the state's `has_book` comes from the
|
||||
/// item's `block_entity_data.Book` for a placer who may use game-master blocks
|
||||
/// (`minecraft:lectern` is in `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`, so the
|
||||
/// block entity only takes the book for one too), and not for anyone else.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_placed_lectern_takes_has_book_from_its_item() {
|
||||
let mut game = server_game();
|
||||
game.set_mode_id(1);
|
||||
game.ui.registries.permission = 2;
|
||||
*game.held_mut() = Some(lectern_item());
|
||||
assert!(game.use_hand(Some(&click([8, 64, 8]))).unwrap());
|
||||
assert!(
|
||||
matches!(game.world.block_entities.get(&(8, 64, 8)), Some(BlockEntity::Lectern(l)) if l.book.is_some())
|
||||
);
|
||||
// A two-page book on page 0: `floor(0 / 1 * 14) + 1`.
|
||||
assert_eq!(state_at(&game, [8, 64, 8]), ("true".into(), 1));
|
||||
|
||||
// The same item from a placer who may not use game-master blocks gains
|
||||
// neither the state nor the block entity book, so the comparator reads 0.
|
||||
game.ui.registries.permission = 0;
|
||||
*game.held_mut() = Some(lectern_item());
|
||||
assert!(game.use_hand(Some(&click([10, 64, 8]))).unwrap());
|
||||
assert!(
|
||||
matches!(game.world.block_entities.get(&(10, 64, 8)), Some(BlockEntity::Lectern(l)) if l.book.is_none())
|
||||
);
|
||||
assert_eq!(state_at(&game, [10, 64, 8]), ("false".into(), 0));
|
||||
}
|
||||
|
||||
fn record_events(record: bool) {
|
||||
TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = record.then(Vec::new));
|
||||
}
|
||||
|
||||
/// The `block_change` game events posted since `record_events(true)`, with
|
||||
/// their position and source entity.
|
||||
fn block_changes() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
|
||||
TEST_POSTED_EVENTS.with(|e| {
|
||||
let events = e.borrow();
|
||||
let changes = events.as_ref().unwrap().iter();
|
||||
changes
|
||||
.filter(|(e, _, _)| *e == "minecraft:block_change")
|
||||
.copied()
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// `LecternBlock.placeBook` -> `resetBookState` with the player as the source
|
||||
/// entity, and `LecternBlockEntity.onBookItemRemove` -> `resetBookState(null,
|
||||
/// ...)`: one `block_change` at the lectern each way, and `has_book` follows.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_book_going_on_or_off_posts_one_block_change() {
|
||||
let mut game = server_game();
|
||||
let key = (8, 64, 8);
|
||||
lectern(&mut game, [8, 64, 8], false, None);
|
||||
*game.held_mut() = Some(ItemStack::new("minecraft:written_book", 1));
|
||||
record_events(true);
|
||||
assert!(game.use_hand(Some(&click([8, 64, 8]))).unwrap());
|
||||
assert_eq!(
|
||||
block_changes(),
|
||||
[(
|
||||
"minecraft:block_change",
|
||||
[8.5, 64.5, 8.5],
|
||||
Some(Who::Player)
|
||||
)]
|
||||
);
|
||||
assert_eq!(state_at(&game, [8, 64, 8]).0, "true");
|
||||
|
||||
// Taking it back off through the lectern's menu.
|
||||
record_events(true);
|
||||
let mut menu = game.lectern_menu(key).expect("the lectern's menu");
|
||||
menu.set_slot(&game.menu_env(), 0, None);
|
||||
game.lectern_menu_done(key, &menu);
|
||||
assert_eq!(
|
||||
block_changes(),
|
||||
[("minecraft:block_change", [8.5, 64.5, 8.5], None)]
|
||||
);
|
||||
assert_eq!(state_at(&game, [8, 64, 8]).0, "false");
|
||||
record_events(false);
|
||||
}
|
||||
@@ -19,7 +19,6 @@ use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::{LegacyRandom, RandomSource},
|
||||
};
|
||||
use horizon_world::level::Ticks;
|
||||
|
||||
pub(super) struct Bolt {
|
||||
/// The entity id a server's players know it by (0 until it has one).
|
||||
@@ -66,13 +65,6 @@ fn in_viewer_box(bolt: [f64; 3], p: [f64; 3]) -> bool {
|
||||
&& p[1] >= bolt[1] - 15.0
|
||||
&& p[1] <= bolt[1] + 21.0
|
||||
}
|
||||
|
||||
/// `ServerLevel.tickThunder` -> `Level.getBlockRandomPos(minX, 0, minZ, 15)`:
|
||||
/// picks the strike column from the level's separate `randValue` LCG.
|
||||
fn thunder_strike_column(ticks: &mut Ticks, cx: i32, cz: i32) -> (i32, i32) {
|
||||
let r = ticks.block_random_pos(cx * 16, 0, cz * 16, 15);
|
||||
(r.x, r.z)
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `ServerChunkCache.tickSpawningChunk`: one `tickThunder` roll per
|
||||
/// spawning chunk (within 128 blocks of a player) that is in entity
|
||||
@@ -98,7 +90,8 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
// getBlockRandomPos(minX, 0, minZ, 15)
|
||||
let (x, z) = thunder_strike_column(&mut self.block_ticks, cx, cz);
|
||||
let r = self.world.random.next_int();
|
||||
let (x, z) = (cx * 16 + (r & 15), cz * 16 + (r >> 16 & 15));
|
||||
let Some(pos) = self.lightning_target(x, z) else {
|
||||
continue;
|
||||
};
|
||||
@@ -886,21 +879,6 @@ mod tests {
|
||||
assert!(!in_viewer_box(bolt, [15.5, 64.0, 0.0]));
|
||||
}
|
||||
|
||||
/// `ServerLevel.tickThunder` picks its strike column from `Level.randValue`
|
||||
/// (`Ticks::block_random_pos`), using bits `2..=5` for X and `10..=13` for Z
|
||||
/// rather than drawing from `Level.random`.
|
||||
#[test]
|
||||
fn thunder_strike_column_uses_block_random_pos_lcg_bits() {
|
||||
let mut ticks = Ticks::new(12345);
|
||||
let mut expected_lcg = 12345i32;
|
||||
for (cx, cz) in [(0, 0), (-3, 5), (12, -8)] {
|
||||
expected_lcg = expected_lcg.wrapping_mul(3).wrapping_add(1_013_904_223);
|
||||
let r = expected_lcg >> 2;
|
||||
let expected = (cx * 16 + (r & 15), cz * 16 + ((r >> 8) & 15));
|
||||
assert_eq!(thunder_strike_column(&mut ticks, cx, cz), expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Pig.thunderHit`: striking a pig with lightning converts it to a zombified
|
||||
/// piglin while preserving age (#7510).
|
||||
#[test]
|
||||
|
||||
@@ -122,61 +122,6 @@ fn a_rocket_posts_its_shot_once_and_a_silent_one_launches_quietly() {
|
||||
assert_eq!(game.audio.heard.get(launch), Some(&1));
|
||||
});
|
||||
}
|
||||
/// #14440: an orb's move adds its drop to the fall outside water; landing
|
||||
/// after a fall posts HIT_GROUND and forgets the fall (`Entity.checkFallDamage`);
|
||||
/// in water the drop adds nothing, so a landing posts nothing.
|
||||
#[test]
|
||||
#[ignore = "prepared resources; actual registered load, Game tick and posted events"]
|
||||
fn a_landing_orb_posts_hit_ground_after_a_fall() {
|
||||
use horizon_client::entities::OrbMove;
|
||||
with_game(|game| {
|
||||
assert!(game.run_command("summon experience_orb 6.5 200 10.5 {Value:1,fall_distance:2d}"));
|
||||
let i = game.orbs.len() - 1;
|
||||
let from = Vec3::new(6.5, 200.5, 10.5);
|
||||
let down = Vec3::new(0.0, -0.5, 0.0);
|
||||
start_events();
|
||||
// In the air: the drop adds to the fall.
|
||||
game.orbs[i].on_ground = false;
|
||||
game.orb_moved(
|
||||
i,
|
||||
OrbMove {
|
||||
from,
|
||||
moved: down,
|
||||
in_water: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(game.orbs[i].fall_distance, 2.5);
|
||||
// On the ground: HIT_GROUND, then the fall is gone.
|
||||
game.orbs[i].on_ground = true;
|
||||
game.orb_moved(
|
||||
i,
|
||||
OrbMove {
|
||||
from,
|
||||
moved: down,
|
||||
in_water: false,
|
||||
},
|
||||
);
|
||||
assert_eq!(game.orbs[i].fall_distance, 0.0);
|
||||
// In water with no fall: no drop, so no landing event.
|
||||
game.orb_moved(
|
||||
i,
|
||||
OrbMove {
|
||||
from,
|
||||
moved: down,
|
||||
in_water: true,
|
||||
},
|
||||
);
|
||||
let events: Vec<_> = crate::game::sculk::TEST_POSTED_EVENTS.with(|v| {
|
||||
std::mem::take(v.borrow_mut().as_mut().unwrap())
|
||||
.into_iter()
|
||||
.filter(|e| e.0 == "minecraft:hit_ground")
|
||||
.collect()
|
||||
});
|
||||
stop_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(game.orbs[i].fall_distance, 0.0);
|
||||
});
|
||||
}
|
||||
/// #14318: an angled rocket falling onto stone builds up its fall and posts
|
||||
/// HIT_GROUND when it lands (`Entity.checkFallDamage`); one falling into water
|
||||
/// splashes once (`Entity.doWaterSplashEffect`: the sound and SPLASH) and lands
|
||||
|
||||
@@ -146,10 +146,6 @@ pub(super) struct Minecart {
|
||||
pub push: Vec3,
|
||||
/// TNT minecarts: ticks until the explosion, -1 when not primed.
|
||||
pub fuse: i32,
|
||||
/// TNT minecarts: `explosionPowerBase` and `explosionSpeedFactor`, the
|
||||
/// blast's power at rest and its gain with the cart's speed.
|
||||
pub explosion_power: f32,
|
||||
pub explosion_speed_factor: f32,
|
||||
/// Hopper minecarts: an unpowered activator rail enables them.
|
||||
pub enabled: bool,
|
||||
/// Chest minecarts: the loot table rolled into `items` on load, saved
|
||||
@@ -366,8 +362,6 @@ impl Minecart {
|
||||
fuel: 0,
|
||||
push: Vec3::ZERO,
|
||||
fuse: -1,
|
||||
explosion_power: DEFAULT_EXPLOSION_POWER,
|
||||
explosion_speed_factor: DEFAULT_EXPLOSION_SPEED_FACTOR,
|
||||
enabled: true,
|
||||
loot: None,
|
||||
first_tick: true,
|
||||
@@ -1553,9 +1547,7 @@ impl Game<'_> {
|
||||
}
|
||||
let speed = speed_sqr.sqrt().min(5.0);
|
||||
let r = c.random.next_double();
|
||||
// MinecartTNT.explode: the base power plus the speed's share.
|
||||
let power = (f64::from(c.explosion_power)
|
||||
+ f64::from(c.explosion_speed_factor) * r * 1.5 * speed) as f32;
|
||||
let power = (4.0 + r * 1.5 * speed) as f32;
|
||||
let at = [c.position.x, c.position.y, c.position.z];
|
||||
// The cart is the explosion's source: it is gone before the blast
|
||||
// looks for entities, so it can't hit itself.
|
||||
@@ -2928,11 +2920,6 @@ impl Game<'_> {
|
||||
.render_state(&scene, state.0, m, light, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// `MinecartTNT.DEFAULT_EXPLOSION_POWER_BASE` and `DEFAULT_EXPLOSION_SPEED_FACTOR`.
|
||||
const DEFAULT_EXPLOSION_POWER: f32 = 4.0;
|
||||
const DEFAULT_EXPLOSION_SPEED_FACTOR: f32 = 1.0;
|
||||
|
||||
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
|
||||
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
|
||||
/// TNT cart.
|
||||
|
||||
@@ -32,8 +32,6 @@ impl Minecart {
|
||||
}
|
||||
map.remove(&JavaString::from("DisplayOffset"));
|
||||
map.remove(&JavaString::from("DisplayState"));
|
||||
map.remove(&JavaString::from("explosion_power"));
|
||||
map.remove(&JavaString::from("explosion_speed_factor"));
|
||||
crate::game::boats::put_ticks_frozen(map, self.ticks_frozen);
|
||||
let mut put = |k: &str, v: Tag| {
|
||||
map.insert(JavaString::from(k), v);
|
||||
@@ -64,26 +62,7 @@ impl Minecart {
|
||||
put("PushZ", Tag::Double(self.push.z.to_bits()));
|
||||
put("Fuel", Tag::Short(self.fuel as i16));
|
||||
}
|
||||
// MinecartTNT.addAdditionalSaveData: the powers only off their defaults.
|
||||
CartKind::Tnt => {
|
||||
put("fuse", Tag::Int(self.fuse));
|
||||
for (key, value, default) in [
|
||||
(
|
||||
"explosion_power",
|
||||
self.explosion_power,
|
||||
DEFAULT_EXPLOSION_POWER,
|
||||
),
|
||||
(
|
||||
"explosion_speed_factor",
|
||||
self.explosion_speed_factor,
|
||||
DEFAULT_EXPLOSION_SPEED_FACTOR,
|
||||
),
|
||||
] {
|
||||
if value != default {
|
||||
put(key, Tag::Float(value.to_bits()));
|
||||
}
|
||||
}
|
||||
}
|
||||
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
|
||||
// BaseCommandBlock.save (its CustomName shares the entity's key).
|
||||
CartKind::CommandBlock => {
|
||||
for (key, value) in self.command.save() {
|
||||
@@ -172,21 +151,6 @@ impl Minecart {
|
||||
// readAdditionalSaveData: the saved push has no y.
|
||||
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
|
||||
c.fuse = int("fuse").unwrap_or(-1);
|
||||
if kind == CartKind::Tnt {
|
||||
// MinecartTNT.readAdditionalSaveData: getFloatOr, kept to 0..128.
|
||||
let float = |k: &str, default: f32| match tag.get(k) {
|
||||
Some(Tag::Float(v)) => f32::from_bits(*v),
|
||||
Some(Tag::Double(v)) => f64::from_bits(*v) as f32,
|
||||
Some(Tag::Long(v)) => *v as f32,
|
||||
Some(other) => {
|
||||
horizon_client_core::nbt_path::int_value(other).map_or(default, |v| v as f32)
|
||||
}
|
||||
None => default,
|
||||
};
|
||||
c.explosion_power = float("explosion_power", DEFAULT_EXPLOSION_POWER).clamp(0.0, 128.0);
|
||||
c.explosion_speed_factor =
|
||||
float("explosion_speed_factor", DEFAULT_EXPLOSION_SPEED_FACTOR).clamp(0.0, 128.0);
|
||||
}
|
||||
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
|
||||
c.custom_display = tag.get("DisplayState").cloned();
|
||||
c.custom_offset = int("DisplayOffset");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Custom names: `NameTagItem.interactLivingEntity` and the name tag
|
||||
//! (`EntityRenderer.submitNameTag`): above the head, facing the camera,
|
||||
//! on a translucent black background, shown when the name is visible or the
|
||||
//! mob is under the crosshair within its name-tag distance (64 by default), as its team's name tag
|
||||
//! 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,
|
||||
@@ -260,12 +260,10 @@ impl Game<'_> {
|
||||
}
|
||||
None => return None,
|
||||
};
|
||||
// The tag's per-entity distance (vanilla's default is 64) is
|
||||
// measured to the entity: copy the name only for a mob the frame
|
||||
// can show it on.
|
||||
// The tag's 64 block distance is measured to the entity: copy the
|
||||
// name only for a mob the frame can show it on.
|
||||
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
|
||||
let max = m.name_tag_distance();
|
||||
(at.distance_squared(camera) < max * max).then_some((m, name, false))
|
||||
(at.distance_squared(camera) < 64.0 * 64.0).then_some((m, name, false))
|
||||
});
|
||||
let own = self.own_name_tag().map(|(m, _)| m.id);
|
||||
let players = players
|
||||
@@ -287,7 +285,7 @@ impl Game<'_> {
|
||||
}
|
||||
// EntityAttachment.NAME_TAG: 0.5 above the box of the current pose.
|
||||
let p = name_tag_at(m, alpha);
|
||||
// The name tag distance is measured to the entity.
|
||||
// The name tag distance (64) is measured to the entity.
|
||||
let distance_sqr =
|
||||
glam::DVec3::new(m.position.x, m.position.y, m.position.z).distance_squared(camera);
|
||||
// Match the model's renderer-specific box, not the tag anchor: the
|
||||
@@ -299,7 +297,6 @@ impl Game<'_> {
|
||||
distance_sqr,
|
||||
(lo - camera - inflate).as_vec3(),
|
||||
(hi - camera + inflate).as_vec3(),
|
||||
m.name_tag_distance(),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -340,13 +337,12 @@ impl Game<'_> {
|
||||
// MobRenderer.shouldShowName: a visible name, or the mob under the
|
||||
// crosshair; players (LivingEntityRenderer) need neither.
|
||||
// `ClientMannequin.belowNameDisplay`: the description is `scoreText`, drawn
|
||||
// within the below-name distance (10 by default) whether the name
|
||||
// shows or not.
|
||||
// within the below-name distance (10) whether the name shows or not.
|
||||
let description = (m.species() == horizon_client::mobs::Species::Mannequin
|
||||
&& distance_sqr < m.below_name_distance().powi(2))
|
||||
.then(|| m.extra.description.component())
|
||||
.flatten()
|
||||
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
|
||||
&& distance_sqr < 10.0 * 10.0)
|
||||
.then(|| m.extra.description.component())
|
||||
.flatten()
|
||||
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
|
||||
let is_own = Some(m.id) == own;
|
||||
let name_shown = sb::name_shown(team, viewer, view)
|
||||
&& (!is_own || !view.is_vehicle)
|
||||
@@ -404,14 +400,14 @@ impl Game<'_> {
|
||||
.unwrap_or(shown);
|
||||
let mut lines = Vec::new();
|
||||
// EntityRenderer.extractNameTags: the score shows within the
|
||||
// per-entity below-name distance (10 by default), under the name.
|
||||
// below-name distance (10), under the name.
|
||||
let mut lift = 0.0;
|
||||
if m.species() == horizon_client::mobs::Species::Mannequin {
|
||||
if let Some(text) = description {
|
||||
lines.push((text, 0.0));
|
||||
lift = f64::from(9.0f32 * 1.15 * 0.025);
|
||||
}
|
||||
} else if distance_sqr < m.below_name_distance().powi(2)
|
||||
} else if distance_sqr < 10.0 * 10.0
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
@@ -470,7 +466,6 @@ impl Game<'_> {
|
||||
distance_sqr,
|
||||
(origin - half).as_vec3(),
|
||||
(origin + glam::DVec3::new(half.x, height + grow, half.z)).as_vec3(),
|
||||
a.name_tag_distance(),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -512,7 +507,7 @@ impl Game<'_> {
|
||||
let mut lines = Vec::new();
|
||||
let mut lift = 0.0;
|
||||
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
|
||||
if distance_sqr < a.below_name_distance().powi(2)
|
||||
if distance_sqr < 10.0 * 10.0
|
||||
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
|
||||
{
|
||||
lines.push((score.resolve_legacy(), 0.0));
|
||||
@@ -834,10 +829,8 @@ fn name_tag_in_view(
|
||||
distance_sqr: f64,
|
||||
min: glam::Vec3,
|
||||
max: glam::Vec3,
|
||||
max_distance: f64,
|
||||
) -> bool {
|
||||
distance_sqr < max_distance * max_distance
|
||||
&& frustum.is_none_or(|view| view.intersects(min, max))
|
||||
distance_sqr < 64.0 * 64.0 && frustum.is_none_or(|view| view.intersects(min, max))
|
||||
}
|
||||
|
||||
fn first_mob_indices(mobs: &[Mob]) -> std::collections::HashMap<u32, usize> {
|
||||
@@ -1152,8 +1145,7 @@ mod tests {
|
||||
Some(&View),
|
||||
181.0,
|
||||
glam::Vec3::new(-0.8, 8.5, -10.8),
|
||||
glam::Vec3::new(0.8, 11.3, -9.2),
|
||||
64.0
|
||||
glam::Vec3::new(0.8, 11.3, -9.2)
|
||||
));
|
||||
for (offset, distance, expected) in [
|
||||
([0.0, 0.0, -10.0], 100.0, true),
|
||||
@@ -1169,21 +1161,11 @@ mod tests {
|
||||
Some(&View),
|
||||
distance,
|
||||
glam::Vec3::from_array(offset) - glam::Vec3::splat(0.5),
|
||||
glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5),
|
||||
64.0
|
||||
glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5)
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
// A server-synced `minecraft:name_tag_distance` of 5 hides a tag
|
||||
// six blocks away even though the vanilla default reaches 64.
|
||||
assert!(!name_tag_in_view(
|
||||
Some(&View),
|
||||
36.0,
|
||||
glam::Vec3::splat(-0.5),
|
||||
glam::Vec3::splat(0.5),
|
||||
5.0
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -702,19 +702,7 @@ impl Game<'_> {
|
||||
});
|
||||
self.portals.before = before;
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
// SulfurCube.tickFuse runs before Entity.handlePortal. A cube removed
|
||||
// there must neither decrement its cooldown nor travel before its blast.
|
||||
let removed_before_portal: std::collections::HashSet<u32> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|_| !vehicles)
|
||||
.filter(|m| horizon_client::mobs::sulfur_cube::removes_before_portal_tick(m))
|
||||
.map(|m| m.id)
|
||||
.collect();
|
||||
for (id, allowed, delay) in ids {
|
||||
if removed_before_portal.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let Some(state) = self.portals.entities.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -258,7 +258,7 @@ impl Game<'_> {
|
||||
.get_or_insert_with(random_source::entity)
|
||||
.next_float()
|
||||
};
|
||||
let (stays, step) = {
|
||||
let stays = {
|
||||
let audio = &mut self.audio;
|
||||
let mut on_sound = |pos: [f64; 3], pitch: f32| {
|
||||
audio.play("minecraft:entity.generic.burn", Some(pos), 0.4, pitch);
|
||||
@@ -280,7 +280,6 @@ impl Game<'_> {
|
||||
// orb that is done is removed.
|
||||
let snow = (o.previous, o.position, o.bounds(), o.velocity);
|
||||
self.entity_powder_snow_particles(snow.0, snow.1, snow.2, snow.3, false);
|
||||
self.orb_moved(i, step);
|
||||
if !stays {
|
||||
self.orbs.remove(i);
|
||||
}
|
||||
@@ -305,30 +304,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.move`'s fall bookkeeping after orb `i`'s move: a move of a
|
||||
/// block or more casts for a reset block or water, `checkFallDamage` adds
|
||||
/// the drop outside water and, on the ground after a fall, posts
|
||||
/// HIT_GROUND, then forgets it. `Block.fallOn` (farmland's random draw,
|
||||
/// honey's slide) is not run for an orb yet, nor for the other entities
|
||||
/// that are not living.
|
||||
pub(super) fn orb_moved(&mut self, i: usize, step: horizon_client::entities::OrbMove) {
|
||||
let o = &self.orbs[i];
|
||||
let mut fall = self.vehicle_fall_reset(o.fall_distance, step.from, step.moved);
|
||||
if !step.in_water && step.moved.y < 0.0 {
|
||||
fall -= f64::from(step.moved.y as f32);
|
||||
}
|
||||
if o.on_ground {
|
||||
if fall > 0.0 {
|
||||
let (position, bounds) = (o.position, o.bounds());
|
||||
self.post_landing_event(position, bounds, false);
|
||||
}
|
||||
fall = 0.0;
|
||||
}
|
||||
self.orbs[i].fall_distance = fall;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{find_join, first_join};
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! A page drawn as its own screen (`horizon_hooks::pages::Place::PauseFriends`, what the pause
|
||||
//! menu's vanilla Friends icon opens), the way a mod adds a screen to vanilla: the menu
|
||||
//! background over the paused world, the title centred at the top, the rows centred down the
|
||||
//! middle, footer buttons beside Done at the bottom (`HeaderAndFooterLayout`).
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
multiplayer::wrap,
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_hooks::pages::{self, Act, Page, Place, Row};
|
||||
|
||||
/// What a click on the screen hit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Hit {
|
||||
/// The page's row with this index.
|
||||
Row(usize),
|
||||
Done,
|
||||
}
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: &'static str,
|
||||
hit: Hit,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
|
||||
/// The centred lines (text, colour, y) and the buttons.
|
||||
fn layout(
|
||||
atlas: &HudAtlas,
|
||||
(w, h): (f32, f32),
|
||||
rows: &[Row],
|
||||
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
|
||||
let width = (w - 40.0).min(310.0);
|
||||
let cx = (w / 2.0).floor();
|
||||
let mut lines = Vec::new();
|
||||
let mut buttons = Vec::new();
|
||||
let mut footer = Vec::new();
|
||||
let mut y = 40.0;
|
||||
// A half-width button waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.iter().enumerate() {
|
||||
let beside = std::mem::take(&mut half_open);
|
||||
let (text, quiet, groups) = match row {
|
||||
Row::Text {
|
||||
text,
|
||||
quiet,
|
||||
groups,
|
||||
..
|
||||
} => (text.clone(), *quiet, *groups),
|
||||
Row::Note(text) => (text.clone(), true, false),
|
||||
Row::Heading(key) => (horizon_client::lang::get(key), true, false),
|
||||
Row::Space(px) => {
|
||||
y += f32::from(*px);
|
||||
continue;
|
||||
}
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
footer: true,
|
||||
..
|
||||
} => {
|
||||
footer.push((r, *label, *active));
|
||||
continue;
|
||||
}
|
||||
Row::Button {
|
||||
label,
|
||||
active,
|
||||
half,
|
||||
..
|
||||
} => {
|
||||
let (x, w) = match (*half, beside) {
|
||||
(true, true) => {
|
||||
y -= 30.0;
|
||||
(cx + 4.0, 150.0)
|
||||
}
|
||||
(true, false) => {
|
||||
half_open = true;
|
||||
(cx - 154.0, 150.0)
|
||||
}
|
||||
(false, _) => (cx - 100.0, 200.0),
|
||||
};
|
||||
buttons.push(Button {
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
label,
|
||||
hit: Hit::Row(r),
|
||||
active: *active,
|
||||
});
|
||||
y += 30.0;
|
||||
continue;
|
||||
}
|
||||
// Settings rows belong in the options shell.
|
||||
_ => continue,
|
||||
};
|
||||
let wrapped = if groups {
|
||||
groups_lines(atlas, &text, width)
|
||||
} else {
|
||||
wrap(&text, width, atlas)
|
||||
};
|
||||
// Text that would run into the footer is left out.
|
||||
if y + 10.0 * wrapped.len() as f32 > h - 33.0 {
|
||||
continue;
|
||||
}
|
||||
let color = if quiet { grey() } else { WHITE };
|
||||
for line in wrapped {
|
||||
lines.push((line, color, y));
|
||||
y += 10.0;
|
||||
}
|
||||
}
|
||||
// The footer: the page's buttons, then Done; Done alone is wider.
|
||||
footer.push((usize::MAX, "gui.done", true));
|
||||
let (each, gap) = if footer.len() == 1 {
|
||||
(200.0, 0.0)
|
||||
} else {
|
||||
(150.0, 8.0)
|
||||
};
|
||||
let total = footer.len() as f32 * (each + gap) - gap;
|
||||
let mut x = cx - total / 2.0;
|
||||
for (r, label, active) in footer {
|
||||
let hit = if r == usize::MAX {
|
||||
Hit::Done
|
||||
} else {
|
||||
Hit::Row(r)
|
||||
};
|
||||
buttons.push(Button {
|
||||
x,
|
||||
y: h - 27.0,
|
||||
w: each,
|
||||
label,
|
||||
hit,
|
||||
active,
|
||||
});
|
||||
x += each + gap;
|
||||
}
|
||||
(lines, buttons)
|
||||
}
|
||||
|
||||
/// `-`-separated groups packed into lines of at most `width`, never broken inside a group.
|
||||
fn groups_lines(atlas: &HudAtlas, text: &str, width: f32) -> Vec<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for group in text.split('-') {
|
||||
match lines.last_mut() {
|
||||
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
|
||||
l.push('-');
|
||||
l.push_str(group);
|
||||
}
|
||||
_ => lines.push(group.to_owned()),
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn draw(batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), title: &str, rows: &[Row]) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
|
||||
let title = Text::translate(title, &[]);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), rows);
|
||||
for (line, color, y) in lines {
|
||||
let lw = HudBatch::text_width(atlas, &line);
|
||||
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
|
||||
}
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
if b.hit == Hit::Done {
|
||||
horizon_client::widgets::declare_action(
|
||||
horizon_client::widgets::Action::Done,
|
||||
[b.x, b.y, b.w, 20.0],
|
||||
b.active,
|
||||
);
|
||||
}
|
||||
let label = Text::translate(b.label, &[]);
|
||||
ui::button(batch, atlas, b.x, b.y, b.w, 20.0, &label, b.active, hover);
|
||||
}
|
||||
}
|
||||
|
||||
/// The active button under a click.
|
||||
fn click(atlas: &HudAtlas, size: (f32, f32), cursor: (f32, f32), rows: &[Row]) -> Option<Hit> {
|
||||
layout(atlas, size, rows)
|
||||
.1
|
||||
.into_iter()
|
||||
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
|
||||
.map(|b| b.hit)
|
||||
}
|
||||
|
||||
/// The page the Friends icon opens: the first registered there.
|
||||
fn friends_page() -> Option<&'static dyn Page> {
|
||||
pages::engine::all()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|page| page.place() == Place::PauseFriends)
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Whether the pause menu's Friends icon is active: a page is registered for it and this
|
||||
/// window plays a singleplayer world (another server's world can't be shared).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.can_share() && friends_page().is_some()
|
||||
}
|
||||
|
||||
/// The Friends icon's page. Its rows are asked each time it is drawn.
|
||||
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
|
||||
let Some(page) = friends_page() else {
|
||||
// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
|
||||
self.screen = Screen::Paused;
|
||||
return;
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
page.rows(&mut rows);
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
horizon_client::widgets::ScreenInfo::new("friends", super::friends::TITLE)
|
||||
});
|
||||
draw(batch, &self.gui.atlas, self.cursor, page.title(), &rows);
|
||||
}
|
||||
|
||||
pub(super) fn friends_screen_click(&mut self) {
|
||||
let Some(page) = friends_page() else {
|
||||
return;
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
page.rows(&mut rows);
|
||||
let Some(hit) = click(&self.gui.atlas, self.gui_size(), self.cursor, &rows) else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
match hit {
|
||||
Hit::Row(r) => page.act(r, Act::Press),
|
||||
Hit::Done => self.screen = Screen::Paused,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
fn at(buttons: &[Button], hit: Hit) -> &Button {
|
||||
buttons.iter().find(|b| b.hit == hit).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn halves_sit_side_by_side_and_footer_buttons_beside_done() {
|
||||
let atlas = atlas();
|
||||
let size = (427.0, 240.0);
|
||||
let rows = [
|
||||
Row::text("Invite Code").quiet(),
|
||||
Row::Space(2),
|
||||
Row::text("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0").groups(),
|
||||
Row::Space(6),
|
||||
Row::button("copy").half().active(false),
|
||||
Row::button("stop").half(),
|
||||
Row::text("Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address)."),
|
||||
Row::button("help").footer(),
|
||||
];
|
||||
let (lines, buttons) = layout(&atlas, size, &rows);
|
||||
let (copy, stop) = (at(&buttons, Hit::Row(4)), at(&buttons, Hit::Row(5)));
|
||||
// 213 is the middle: two 150-wide halves 8 apart around it, as vanilla's pairs.
|
||||
assert_eq!(
|
||||
(copy.x, copy.w, stop.x, stop.w),
|
||||
(59.0, 150.0, 217.0, 150.0)
|
||||
);
|
||||
assert_eq!(copy.y, stop.y);
|
||||
let (help, done) = (at(&buttons, Hit::Row(7)), at(&buttons, Hit::Done));
|
||||
assert_eq!(
|
||||
(help.x, done.x, help.y, done.y),
|
||||
(59.0, 217.0, 213.0, 213.0)
|
||||
);
|
||||
// The code breaks between its groups only.
|
||||
let code: Vec<_> = lines
|
||||
.iter()
|
||||
.filter(|(_, c, _)| *c == WHITE)
|
||||
.map(|(l, ..)| l)
|
||||
.collect();
|
||||
assert!(code[0].starts_with("0B33K-") && code.iter().all(|l| !l.ends_with('-')));
|
||||
assert!(
|
||||
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
|
||||
"above the footer"
|
||||
);
|
||||
// An inactive button takes no click; Done alone is 200 wide.
|
||||
assert_eq!(
|
||||
click(&atlas, size, (copy.x + 1.0, copy.y + 1.0), &rows),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
click(&atlas, size, (stop.x + 1.0, stop.y + 1.0), &rows),
|
||||
Some(Hit::Row(5))
|
||||
);
|
||||
let (_, alone) = layout(&atlas, size, &[Row::button("share")]);
|
||||
assert_eq!(at(&alone, Hit::Done).w, 200.0);
|
||||
assert_eq!(at(&alone, Hit::Row(0)).x, 113.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_would_reach_the_footer_is_left_out() {
|
||||
let atlas = atlas();
|
||||
let rows = [
|
||||
Row::Space(150),
|
||||
Row::text("first"),
|
||||
Row::text("too low").quiet(),
|
||||
];
|
||||
let (lines, _) = layout(&atlas, (427.0, 240.0), &rows);
|
||||
// 40 + 150 = 190: one line fits above 207, the next (200..210) doesn't.
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(lines[0].0, "first");
|
||||
}
|
||||
}
|
||||
@@ -44,10 +44,6 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.position(|e| wants(self, e) && reach.intersects(e.bounds()));
|
||||
if let Some(j) = taken {
|
||||
let before = self.dropped[j].clone();
|
||||
// `Panda.pickUpItem`'s `onItemPickup` comes first: the
|
||||
// advancement of whoever threw it.
|
||||
self.mob_item_pickup_trigger(i, &before);
|
||||
let e = self.remove_dropped(j);
|
||||
self.add_pickup(Taken::Item(Box::new(e.clone())), Collector::Mob(id));
|
||||
panda::hold(&mut self.mobs[i], &e.stack.item, e.stack.count, true);
|
||||
@@ -191,98 +187,3 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pickup_tests {
|
||||
use super::*;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
use horizon_client::advancements as adv;
|
||||
use horizon_client::motion::PlayerMotion;
|
||||
use horizon_world::lazy_json::LazyJson;
|
||||
|
||||
const ADV: &str = "horizon:test/panda_pickup";
|
||||
const TRIGGER: &str = "minecraft:thrown_item_picked_up_by_entity";
|
||||
const THROWER: [i32; 4] = [413, 0, 0, 1];
|
||||
|
||||
/// An advancement on the pickup trigger, as a data pack would add one: no
|
||||
/// vanilla advancement tests it, so the tree needs one of its own.
|
||||
fn install_pickup_advancement(game: &mut Game<'_>) {
|
||||
let mut tree = std::sync::Arc::new(adv::Tree::default());
|
||||
let one = std::sync::Arc::get_mut(&mut tree).unwrap();
|
||||
one.list.push(adv::Advancement {
|
||||
id: ADV.into(),
|
||||
parent: None,
|
||||
display: None,
|
||||
criteria: vec![adv::Criterion {
|
||||
name: "pickup".into(),
|
||||
trigger: TRIGGER.into(),
|
||||
conditions: LazyJson::new(&serde_json::json!({})),
|
||||
}],
|
||||
requirements: vec![vec!["pickup".into()]],
|
||||
rewards: Default::default(),
|
||||
});
|
||||
one.by_id.insert(ADV.into(), 0);
|
||||
one.by_trigger.insert(TRIGGER.into(), vec![(0, 0)]);
|
||||
game.adv.tree = tree;
|
||||
}
|
||||
|
||||
/// `Panda.pickUpItem` calls `onItemPickup`, which fires
|
||||
/// `CriteriaTriggers.THROWN_ITEM_PICKED_UP_BY_ENTITY` for whoever threw the
|
||||
/// bamboo, as the fox's pickup does (`LivingEntity.onItemPickup`).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_panda_that_takes_a_thrown_item_credits_the_thrower() {
|
||||
let mut game = server_game();
|
||||
game.options.dedicated = None;
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 4.5)).unwrap());
|
||||
game.level.player_uuid = Some(THROWER);
|
||||
game.level.health = 20.0;
|
||||
game.creative = false;
|
||||
// A world of its own under the temporary folder, never the cwd.
|
||||
game.world_root =
|
||||
std::env::temp_dir().join(format!("horizon-panda-pickup-world-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&game.world_root);
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
install_pickup_advancement(&mut game);
|
||||
// `DistanceManager` keeps a chunk entity-ticking only while its whole
|
||||
// 5x5 neighbourhood is resident.
|
||||
for cx in -2..=2 {
|
||||
for cz in -2..=2 {
|
||||
game.world.chunks.insert(
|
||||
(cx, cz),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(cx, cz)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"tick freeze",
|
||||
"fill 0 99 0 15 99 15 stone",
|
||||
"summon minecraft:panda 4.5 100 6.5 {UUID:[I;9,9,9,9],PersistenceRequired:1b,CanPickUpLoot:1b}",
|
||||
"summon minecraft:item 4.5 100 6.5 {Item:{id:\"minecraft:bamboo\",count:1},Thrower:[I;413,0,0,1],PickupDelay:0s}",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
let id = game.mob_by_uuid([9, 9, 9, 9]).unwrap();
|
||||
let holding = |g: &Game<'_>| {
|
||||
g.mobs
|
||||
.iter()
|
||||
.any(|m| m.id == id && m.extra.main_hand.is_some())
|
||||
};
|
||||
for _ in 0..20 {
|
||||
if holding(&game) {
|
||||
break;
|
||||
}
|
||||
assert!(game.run_command("tick step 1"));
|
||||
game.tick_server(false).unwrap();
|
||||
}
|
||||
assert!(holding(&game), "the panda took the bamboo");
|
||||
assert!(
|
||||
game.adv.progress.has(ADV, "pickup"),
|
||||
"the thrower's advancement: {:?}",
|
||||
game.adv.progress.criteria
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&game.world_root);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use super::Game;
|
||||
use horizon_client::mobs::{Species, riders};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Vec3},
|
||||
random::{LegacyRandom, RandomSource},
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -50,12 +50,6 @@ fn mount_rolls(species: Species, baby: bool, natural: bool) -> MountRolls {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SkeletonTrapGoal.tick`: each additional trap horse's horizontal push draws
|
||||
/// two `triangle(0.0, 1.1485)` offsets from the original trap horse's random.
|
||||
fn trap_horse_scatter(random: &mut LegacyRandom) -> (f64, f64) {
|
||||
(random.triangle(0.0, 1.1485), random.triangle(0.0, 1.1485))
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.startRiding` for two mobs.
|
||||
pub(super) fn mount_mob(&mut self, rider: u32, vehicle: u32) {
|
||||
@@ -706,21 +700,18 @@ impl Game<'_> {
|
||||
let Some(other) = self.spawn_mob("minecraft:skeleton_horse", at, true) else {
|
||||
continue;
|
||||
};
|
||||
// SkeletonTrapGoal.createHorse: scatter is drawn after the rider.
|
||||
let (dx, dz) = (
|
||||
self.world.random.triangle(0.0, 1.1485),
|
||||
self.world.random.triangle(0.0, 1.1485),
|
||||
);
|
||||
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == other) {
|
||||
h.invulnerable_time = TRAP_INVULNERABLE;
|
||||
h.extra.tame = true;
|
||||
h.extra.age = 0;
|
||||
h.velocity = Vec3::new(h.velocity.x + dx, h.velocity.y, h.velocity.z + dz);
|
||||
}
|
||||
if let Some(rider) = self.trap_skeleton(at) {
|
||||
self.mount_mob(rider, other);
|
||||
let Some(horse) = self.mobs.iter_mut().find(|m| m.id == id) else {
|
||||
continue;
|
||||
};
|
||||
let (dx, dz) = trap_horse_scatter(&mut horse.random);
|
||||
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == other) {
|
||||
h.velocity = Vec3::new(h.velocity.x + dx, h.velocity.y, h.velocity.z + dz);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -777,59 +768,4 @@ mod tests {
|
||||
assert!(!r(Species::Drowned, true).nautilus);
|
||||
assert!(!r(Species::Zombie, false).camel_husk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trap_horse_scatter_draws_two_triangles_from_original_horse_random() {
|
||||
let mut horse_random = LegacyRandom::new(0x5DEECE66D);
|
||||
let mut expected = horse_random.clone();
|
||||
for _ in 0..3 {
|
||||
let (dx, dz) = trap_horse_scatter(&mut horse_random);
|
||||
assert_eq!(dx, expected.triangle(0.0, 1.1485));
|
||||
assert_eq!(dz, expected.triangle(0.0, 1.1485));
|
||||
}
|
||||
assert_eq!(horse_random.next_long(), expected.next_long());
|
||||
}
|
||||
|
||||
/// `SkeletonTrapGoal.tick`: springing a skeleton trap draws the three
|
||||
/// additional horses' horizontal velocity scatter from the original trap
|
||||
/// horse's own `random` after each rider is created.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn spring_skeleton_trap_draws_scatter_from_trap_horse_after_each_rider() {
|
||||
use horizon_client::chunk::Chunk;
|
||||
use std::sync::Arc;
|
||||
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let at = [8.5, 64.0, 8.5];
|
||||
let trap_id = game
|
||||
.spawn_mob_raw("minecraft:skeleton_horse", at, false)
|
||||
.unwrap();
|
||||
let mut expected_horse_rng = LegacyRandom::new(777);
|
||||
if let Some(horse) = game.mobs.iter_mut().find(|m| m.id == trap_id) {
|
||||
horse.extra.trap = true;
|
||||
horse.random = horizon_core::random::EntityRandom::own(expected_horse_rng.clone());
|
||||
}
|
||||
game.spring_skeleton_trap(trap_id);
|
||||
|
||||
let expected_pushes: Vec<(f64, f64)> = (0..3)
|
||||
.map(|_| trap_horse_scatter(&mut expected_horse_rng))
|
||||
.collect();
|
||||
let horse = game.mobs.iter_mut().find(|m| m.id == trap_id).unwrap();
|
||||
assert!(!horse.extra.trap);
|
||||
assert_eq!(horse.random.next_long(), expected_horse_rng.next_long());
|
||||
|
||||
let extra_horses: Vec<&horizon_client::mobs::Mob> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::SkeletonHorse && m.id != trap_id)
|
||||
.collect();
|
||||
assert_eq!(extra_horses.len(), 3);
|
||||
for (h, (dx, dz)) in extra_horses.iter().zip(expected_pushes) {
|
||||
assert_eq!((h.velocity.x, h.velocity.z), (dx, dz));
|
||||
assert!(h.extra.rider.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ use horizon_client::{
|
||||
collisions::SceneCollisions,
|
||||
};
|
||||
use horizon_client::{
|
||||
entities::nameplate::NameplateDistances,
|
||||
hud::HudBatch,
|
||||
mobs::{Anim, Brain, Mob, MobType},
|
||||
mouse::Rotation,
|
||||
@@ -290,9 +289,9 @@ impl Game<'_> {
|
||||
}
|
||||
// Minecraft.cameraEntity: a server's spectator camera on a mob or
|
||||
// another player.
|
||||
let (entity, descending, entity_kind) = match self.camera_entity() {
|
||||
Some(e) => (
|
||||
e,
|
||||
let (entity, descending, entity_kind) = match self.camera_mob() {
|
||||
Some(m) => (
|
||||
super::remote_camera::mob_camera_entity(m),
|
||||
false,
|
||||
horizon_world::collision_context::EntityContextKind::Living,
|
||||
),
|
||||
@@ -461,17 +460,11 @@ impl Game<'_> {
|
||||
/// Keep the player's model in step with the player (`LivingEntity.tick`
|
||||
/// body turning, `WalkAnimationState`, the swing and the pose).
|
||||
pub(super) fn tick_player_model(&mut self) {
|
||||
let (scale, nameplate) = self.player.as_mut().map_or_else(
|
||||
|| (1.0, NameplateDistances::default()),
|
||||
|p| {
|
||||
let scale = p.attribute("minecraft:scale").unwrap_or(1.0) as f32;
|
||||
let nameplate = NameplateDistances {
|
||||
name_tag: p.attribute("minecraft:name_tag_distance").ok(),
|
||||
below_name: p.attribute("minecraft:below_name_distance").ok(),
|
||||
};
|
||||
(scale, nameplate)
|
||||
},
|
||||
);
|
||||
let scale = self
|
||||
.player
|
||||
.as_mut()
|
||||
.and_then(|p| p.attribute("minecraft:scale").ok())
|
||||
.unwrap_or(1.0) as f32;
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
self.player_model = None;
|
||||
return;
|
||||
@@ -534,7 +527,6 @@ impl Game<'_> {
|
||||
};
|
||||
m.extra.ints[1] = bed;
|
||||
m.extra.model_scale = scale;
|
||||
m.set_nameplate(nameplate);
|
||||
// The crouch flag is still last tick's here.
|
||||
let (from, at) =
|
||||
player_model_positions(previous_position, position, m.extra.crouching, crouching);
|
||||
|
||||
@@ -176,7 +176,6 @@ mod tests {
|
||||
fn orb_at(x: f64, y: f64, z: f64) -> Taken {
|
||||
let at = horizon_core::geometry::Vec3::new(x, y, z);
|
||||
Taken::Orb(Box::new(ExperienceOrb {
|
||||
fall_distance: 0.0,
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
|
||||
@@ -338,7 +338,6 @@ impl Game<'_> {
|
||||
window.set_cursor(match effective {
|
||||
Cursor::Arrow => winit::window::CursorIcon::Default,
|
||||
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
|
||||
Cursor::Ibeam => winit::window::CursorIcon::Text,
|
||||
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
|
||||
Cursor::ResizeNs => winit::window::CursorIcon::NsResize,
|
||||
Cursor::ResizeEw => winit::window::CursorIcon::EwResize,
|
||||
|
||||
@@ -64,8 +64,6 @@ use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
mod abilities;
|
||||
#[path = "remote_poll.rs"]
|
||||
mod poll;
|
||||
#[path = "remote_attributes.rs"]
|
||||
mod remote_attributes;
|
||||
#[path = "remote_tables.rs"]
|
||||
mod tables;
|
||||
|
||||
@@ -1574,7 +1572,7 @@ impl Game<'_> {
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
// The shared world is closing: its invite code ends with it.
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
@@ -2808,8 +2806,23 @@ impl Game<'_> {
|
||||
if kind == "minecraft:experience_orb" {
|
||||
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
|
||||
self.orbs.retain(|o| o.id != id as u32);
|
||||
self.orbs
|
||||
.push(ExperienceOrb::new(id as u32, at, Vec3::ZERO, 1));
|
||||
self.orbs.push(ExperienceOrb {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: id as u32,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
air: 300,
|
||||
following: None,
|
||||
});
|
||||
}
|
||||
if let Some(t) = mob_type {
|
||||
let mut mob = Mob::new(id as u32, t, at, head);
|
||||
@@ -3245,6 +3258,60 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleUpdateAttributes` for a mob, each value from
|
||||
/// `AttributeInstance.calculateValue`: its maximum health (the hearts drawn
|
||||
/// over its mount), and its `minecraft:scale`, which sizes its box
|
||||
/// (`LivingEntity.getDimensions`) and model (`LivingEntityRenderState.scale`)
|
||||
/// as the local player's does: NPC plugins make their fake players bigger
|
||||
/// with it, datapacks scale any mob.
|
||||
fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
|
||||
let catalog = &self.motion.attributes;
|
||||
let value = |name: &str| {
|
||||
let attribute = catalog.id(name)?;
|
||||
let s = values
|
||||
.iter()
|
||||
.find(|s| s.attribute == attribute.registry_id())?;
|
||||
Some(
|
||||
catalog
|
||||
.attribute(attribute)
|
||||
.definition
|
||||
.sanitize(snapshot_value(s)),
|
||||
)
|
||||
};
|
||||
let (max_health, scale) = (value("minecraft:max_health"), value("minecraft:scale"));
|
||||
let (speed, jump) = (
|
||||
value("minecraft:movement_speed"),
|
||||
value("minecraft:jump_strength"),
|
||||
);
|
||||
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
|
||||
return;
|
||||
};
|
||||
// A horse its rider's client moves goes at its own speed and jumps
|
||||
// its own height (`getSpeed`, `getCustomJump`).
|
||||
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
|
||||
if let Some(speed) = speed {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MovementSpeed,
|
||||
speed,
|
||||
);
|
||||
}
|
||||
if let Some(jump) = jump {
|
||||
m.extra.vector[0] = jump;
|
||||
}
|
||||
}
|
||||
if let Some(max_health) = max_health {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MaxHealth,
|
||||
max_health,
|
||||
);
|
||||
}
|
||||
if let Some(scale) = scale {
|
||||
m.extra.model_scale = scale as f32;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_packet(&mut self, packet: C) -> Result<()> {
|
||||
self.native_client_packet(&packet);
|
||||
match packet {
|
||||
@@ -4498,16 +4565,7 @@ impl Game<'_> {
|
||||
// HUD draws and the health it may hold), reach, speed and so on.
|
||||
// Then the received health again, which the old maximum clamped.
|
||||
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
|
||||
let nameplate = self.remote_nameplate_values(&p.values);
|
||||
self.apply_own_attributes(p.values);
|
||||
if let Some(m) = self.player_model.as_mut() {
|
||||
if let Some(value) = nameplate.name_tag {
|
||||
m.set_name_tag_distance(value);
|
||||
}
|
||||
if let Some(value) = nameplate.below_name {
|
||||
m.set_below_name_distance(value);
|
||||
}
|
||||
}
|
||||
self.apply_remote_health();
|
||||
}
|
||||
C::UpdateAttributes(p) => self.remote_mob_attributes(p.id, &p.values),
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
//! `ClientPacketListener.handleUpdateAttributes` for entity attributes that
|
||||
//! affect the client-side nameplate.
|
||||
use super::{Game, snapshot_value};
|
||||
use horizon_client::entities::nameplate::NameplateDistances;
|
||||
use horizon_protocol::packets::play;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Computes one synced attribute using the catalog's vanilla range.
|
||||
fn remote_attribute_value(
|
||||
&self,
|
||||
name: &str,
|
||||
values: &[play::AttributeSnapshot],
|
||||
) -> Option<f64> {
|
||||
let attribute = self.motion.attributes.id(name)?;
|
||||
let snapshot = values
|
||||
.iter()
|
||||
.find(|snapshot| snapshot.attribute == attribute.registry_id())?;
|
||||
Some(
|
||||
self.motion
|
||||
.attributes
|
||||
.attribute(attribute)
|
||||
.definition
|
||||
.sanitize(snapshot_value(snapshot)),
|
||||
)
|
||||
}
|
||||
|
||||
/// The two attributes read by `LivingEntityRenderer` and
|
||||
/// `EntityRenderer.extractNameTags`.
|
||||
pub(super) fn remote_nameplate_values(
|
||||
&self,
|
||||
values: &[play::AttributeSnapshot],
|
||||
) -> NameplateDistances {
|
||||
NameplateDistances {
|
||||
name_tag: self.remote_attribute_value("minecraft:name_tag_distance", values),
|
||||
below_name: self.remote_attribute_value("minecraft:below_name_distance", values),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClientPacketListener.handleUpdateAttributes` for a mob. The same
|
||||
/// packet can target a player, mob or armor stand.
|
||||
pub(super) fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
|
||||
let max_health = self.remote_attribute_value("minecraft:max_health", values);
|
||||
let scale = self.remote_attribute_value("minecraft:scale", values);
|
||||
let speed = self.remote_attribute_value("minecraft:movement_speed", values);
|
||||
let jump = self.remote_attribute_value("minecraft:jump_strength", values);
|
||||
let nameplate = self.remote_nameplate_values(values);
|
||||
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
|
||||
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id as u32) {
|
||||
if let Some(value) = nameplate.name_tag {
|
||||
a.nameplate.name_tag = Some(value);
|
||||
}
|
||||
if let Some(value) = nameplate.below_name {
|
||||
a.nameplate.below_name = Some(value);
|
||||
}
|
||||
}
|
||||
return;
|
||||
};
|
||||
// A horse its rider's client moves goes at its own speed and jumps
|
||||
// its own height (`getSpeed`, `getCustomJump`).
|
||||
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
|
||||
if let Some(speed) = speed {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MovementSpeed,
|
||||
speed,
|
||||
);
|
||||
}
|
||||
if let Some(jump) = jump {
|
||||
m.extra.vector[0] = jump;
|
||||
}
|
||||
}
|
||||
if let Some(max_health) = max_health {
|
||||
horizon_client::mobs::finalize::set_base(
|
||||
m,
|
||||
horizon_client::mobs::finalize::Attr::MaxHealth,
|
||||
max_health,
|
||||
);
|
||||
}
|
||||
if let Some(scale) = scale {
|
||||
m.extra.model_scale = scale as f32;
|
||||
}
|
||||
if let Some(value) = nameplate.name_tag {
|
||||
m.set_name_tag_distance(value);
|
||||
}
|
||||
if let Some(value) = nameplate.below_name {
|
||||
m.set_below_name_distance(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
camera::{CameraEntity, LivingCamera},
|
||||
entities::ArmorStand,
|
||||
mobs::Mob,
|
||||
};
|
||||
|
||||
@@ -54,35 +53,6 @@ pub(super) fn mob_camera_entity(m: &Mob) -> CameraEntity {
|
||||
}
|
||||
}
|
||||
|
||||
/// `Camera.alignWithEntity`'s view of an armor stand, the usual camera of a
|
||||
/// server's cutscene or menu scene. `ArmorStand.setYHeadRot` keeps its head
|
||||
/// on `yRot`, so its view turns with its yaw; its eyes are its dimensions'
|
||||
/// (`getDimensionsMarker`): none for a marker, `BABY_DIMENSIONS`' 0.9875 for
|
||||
/// a small stand, the type's 1.7775 otherwise.
|
||||
pub(super) fn stand_camera_entity(a: &ArmorStand) -> CameraEntity {
|
||||
let eye_height = if a.marker {
|
||||
0.0
|
||||
} else {
|
||||
a.probe_dimensions().2 as f32
|
||||
};
|
||||
CameraEntity {
|
||||
position: [a.position.x, a.position.y, a.position.z],
|
||||
previous: [a.previous.x, a.previous.y, a.previous.z],
|
||||
x_rot: a.pitch,
|
||||
y_rot: a.yaw,
|
||||
x_rot_o: a.pitch,
|
||||
y_rot_o: a.previous_yaw,
|
||||
eye_height,
|
||||
living: Some(LivingCamera {
|
||||
scale: 1.0,
|
||||
camera_distance: 4.0,
|
||||
sleeping: None,
|
||||
dead: None,
|
||||
}),
|
||||
mount: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `GameRenderer.checkEntityPostEffect`: the chain a spectated creeper, spider
|
||||
/// or enderman puts on the screen.
|
||||
pub(super) fn entity_post_effect(kind: &str) -> Option<&'static str> {
|
||||
@@ -98,9 +68,7 @@ pub(super) fn entity_post_effect(kind: &str) -> Option<&'static str> {
|
||||
impl Game<'_> {
|
||||
/// `ClientPacketListener.handleSetCamera`.
|
||||
pub(super) fn remote_set_camera(&mut self, camera_id: i32) {
|
||||
let id = camera_id as u32;
|
||||
let in_level =
|
||||
self.mobs.iter().any(|m| m.id == id) || self.armor_stands.iter().any(|a| a.id == id);
|
||||
let in_level = self.mobs.iter().any(|m| m.id == camera_id as u32);
|
||||
let player = self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -134,18 +102,6 @@ impl Game<'_> {
|
||||
let id = self.remote_camera_id()?;
|
||||
self.mobs.iter().find(|m| m.id == id)
|
||||
}
|
||||
/// The view of the entity a server attached the camera to, while the
|
||||
/// level still holds it: a mob or an armor stand.
|
||||
pub(super) fn camera_entity(&self) -> Option<CameraEntity> {
|
||||
if let Some(m) = self.camera_mob() {
|
||||
return Some(mob_camera_entity(m));
|
||||
}
|
||||
let id = self.remote_camera_id()?;
|
||||
self.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(stand_camera_entity)
|
||||
}
|
||||
/// The entity under a spectator's crosshair (`Minecraft.hitResult` as an
|
||||
/// `EntityHitResult`): the nearest mob or object before any block.
|
||||
pub(super) fn spectator_click_target(&self) -> Option<i32> {
|
||||
@@ -166,28 +122,7 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{camera_after_packet, entity_post_effect, stand_camera_entity};
|
||||
use horizon_client::entities::ArmorStand;
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// A server's camera on an armor stand looks out of the stand's eyes
|
||||
/// (EntityType.ARMOR_STAND `eyeHeight(1.7775F)`, `BABY_DIMENSIONS`
|
||||
/// 0.9875F for a small one, nothing for a marker), along its yaw.
|
||||
#[test]
|
||||
fn an_armor_stand_holds_the_camera_at_its_eyes() {
|
||||
let mut stand = ArmorStand::new(7, Vec3::new(10.5, 64.0, -3.5), 90.0);
|
||||
stand.yaw = 135.0;
|
||||
stand.pitch = -20.0;
|
||||
let view = stand_camera_entity(&stand);
|
||||
assert_eq!(view.position, [10.5, 64.0, -3.5]);
|
||||
assert_eq!(view.eye_height, 1.7775f32);
|
||||
assert_eq!((view.y_rot_o, view.y_rot), (90.0, 135.0));
|
||||
assert_eq!(view.x_rot, -20.0);
|
||||
stand.small = true;
|
||||
assert_eq!(stand_camera_entity(&stand).eye_height, 0.9875f32);
|
||||
stand.marker = true;
|
||||
assert_eq!(stand_camera_entity(&stand).eye_height, 0.0);
|
||||
}
|
||||
use super::{camera_after_packet, entity_post_effect};
|
||||
|
||||
/// Only a creeper, spider or enderman has a spectator chain.
|
||||
#[test]
|
||||
|
||||
@@ -20,7 +20,7 @@ use super::input_trace::{InputOrigin, TraceEvent, TraceKind};
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{Result, gamepad::Button, keybinds, text::Text, title::world_management};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_system::scrub::Scrubber;
|
||||
use horizon_telemetry::scrub::Scrubber;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
|
||||
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_hooks::share::{LanOptions, ShareView};
|
||||
|
||||
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
|
||||
/// plays P1's world and can't share it; its leaving doesn't close it).
|
||||
fn shares(game: &Game<'_>) -> bool {
|
||||
!game.serving() && !game.options.split_guest
|
||||
}
|
||||
|
||||
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
|
||||
/// horizon-friends (spec #14167). Before the hooks are sealed.
|
||||
#[cfg(feature = "friends")]
|
||||
pub(crate) fn register_friends() {
|
||||
if horizon_hooks::register(&super::friends::HOOKS).is_err()
|
||||
|| horizon_hooks::pages::register(&super::friends::PAGE).is_err()
|
||||
{
|
||||
log::warn!("Play with Friends registered after the game started: sharing is off");
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
|
||||
pub(super) fn share_tick(&mut self) {
|
||||
if shares(self) {
|
||||
horizon_hooks::engine::share(&mut World(self));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
|
||||
pub(super) fn lan_closed_hooks(&self) {
|
||||
if shares(self) {
|
||||
horizon_hooks::engine::lan_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
|
||||
/// to LAN publishes it; another server can't be.
|
||||
pub(super) fn can_share(&self) -> bool {
|
||||
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'g> Game<'g> {
|
||||
/// The view of this window's world, for the engine's own Friends screen and command until
|
||||
/// they move to the crate.
|
||||
#[cfg(feature = "friends")]
|
||||
pub(super) fn share_view(&mut self) -> World<'_, 'g> {
|
||||
World(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
|
||||
|
||||
impl ShareView for World<'_, '_> {
|
||||
fn can_share(&self) -> bool {
|
||||
self.0.can_share()
|
||||
}
|
||||
|
||||
fn lan_port(&self) -> Option<u16> {
|
||||
self.0.lan_port()
|
||||
}
|
||||
|
||||
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
|
||||
self.0
|
||||
.lan_open(LanSettings {
|
||||
port: horizon_client::lan::available_port(),
|
||||
guest_commands: options.guest_commands,
|
||||
force_game_mode: options.force_game_mode,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.0
|
||||
.lan_port()
|
||||
.ok_or_else(|| "the world isn't open to LAN".to_owned())
|
||||
}
|
||||
|
||||
fn close_lan(&mut self) {
|
||||
self.0.lan_close(true);
|
||||
}
|
||||
|
||||
fn players(&self, each: &mut dyn FnMut(&str)) {
|
||||
let g = &self.0;
|
||||
if let Some(host) = &g.lan {
|
||||
for guest in &host.hub.guests {
|
||||
each(&guest.name);
|
||||
}
|
||||
} else if g.integrated.is_some() {
|
||||
// On the server thread: the other players the server lists.
|
||||
let me = g.options.player_name();
|
||||
for name in g.remote_online_names().unwrap_or_default() {
|
||||
if name != me {
|
||||
each(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.0.system_message(Text::literal(text));
|
||||
}
|
||||
|
||||
fn copy(&mut self, text: &str) {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.0.clipboard.set(text);
|
||||
}
|
||||
|
||||
fn open_url(&mut self, url: &str) {
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
|
||||
fn tr(&self, key: &str) -> Option<String> {
|
||||
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
|
||||
}
|
||||
}
|
||||
@@ -21,20 +21,6 @@ mod ram_tests;
|
||||
pub(super) const OFFHAND_SLOT: usize = 40;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `AbstractArrow.onHitEntity` on the player: a piercing arrow passes item blocking, whoever
|
||||
/// shot it, because `applyItemBlocking` reads the direct entity's pierce level.
|
||||
pub(super) fn arrow_hits_player(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
p: &horizon_client::projectiles::Projectile,
|
||||
) -> bool {
|
||||
self.combat.piercing_hit = p.arrow.as_ref().is_some_and(|a| a.pierce > 0);
|
||||
self.mob_hits_player(
|
||||
amount,
|
||||
[p.previous.x, p.previous.y, p.previous.z],
|
||||
"minecraft:arrow",
|
||||
)
|
||||
}
|
||||
/// The stack in an inventory slot, the off hand at `OFFHAND_SLOT`.
|
||||
pub(super) fn stack_in(&self, slot: usize) -> Option<&ItemStack> {
|
||||
if slot == OFFHAND_SLOT {
|
||||
@@ -158,7 +144,6 @@ impl Game<'_> {
|
||||
&& blocked > 0.0
|
||||
{
|
||||
let outcome = BlockOutcome {
|
||||
blocking_item_used: true,
|
||||
blocked,
|
||||
fully_blocked: blocked >= damage,
|
||||
..Default::default()
|
||||
@@ -208,23 +193,22 @@ impl Game<'_> {
|
||||
let pitch = 0.8 + self.world.random.next_float() * 0.4;
|
||||
self.audio.play(sound, at, 1.0, pitch);
|
||||
}
|
||||
if !outcome.blocking_item_used {
|
||||
return;
|
||||
}
|
||||
// BlocksAttacks.hurtBlockingItem: the hit counts the blocking item as
|
||||
// used, before the blocked-damage gate (vanilla awards it on the
|
||||
// server even when the item blocks none of the damage).
|
||||
if self.remote.is_none() {
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
if outcome.blocked <= 0.0 {
|
||||
return;
|
||||
}
|
||||
// ItemDamageFunction.apply, then hurtAndBreak.
|
||||
let (threshold, base, factor) = component.item_damage;
|
||||
let item_damage = ItemDamageFunction {
|
||||
threshold,
|
||||
base,
|
||||
factor,
|
||||
}
|
||||
.apply(outcome.blocked);
|
||||
let item_damage = if outcome.blocked < threshold {
|
||||
0
|
||||
} else {
|
||||
(base + factor * outcome.blocked).floor() as i32
|
||||
};
|
||||
if item_damage > 0
|
||||
&& !self.creative
|
||||
&& let Some(max) = blocking_item_max_damage(
|
||||
@@ -258,7 +242,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if outcome.blocked <= 0.0 || projectile {
|
||||
if projectile {
|
||||
return;
|
||||
}
|
||||
let Some(id) = attacker else {
|
||||
@@ -425,7 +409,6 @@ mod tests {
|
||||
direct: 1,
|
||||
};
|
||||
let outcome = BlockOutcome {
|
||||
blocking_item_used: true,
|
||||
hurt: true,
|
||||
blocked: 5.0,
|
||||
fully_blocked: true,
|
||||
|
||||
@@ -296,32 +296,6 @@ impl Game<'_> {
|
||||
self.open_text_edit(key, front);
|
||||
}
|
||||
}
|
||||
/// Whether `SignBlock.useWithoutItem` on the sign at `pos` consumes the
|
||||
/// click. Vanilla answers: a waxed sign (the failed-interaction sound), a
|
||||
/// side whose lines carry a click action (`executeClickCommandsIfPresent`,
|
||||
/// which runs it), and a player who may build (the text editor). Every
|
||||
/// other sign answers PASS, so `ServerPlayerGameMode.useItemOn` goes on to
|
||||
/// `ItemStack.useOn` and an adventure-mode `can_place_on` stack places
|
||||
/// against it. Vanilla also passes while another player is editing the
|
||||
/// sign and when its text has styled lines; neither is modelled here.
|
||||
pub(super) fn sign_consumes_click(&self, pos: [i32; 3]) -> bool {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
|
||||
return false;
|
||||
};
|
||||
if sign.waxed || self.may_build() {
|
||||
return true;
|
||||
}
|
||||
let side = if self.facing_sign_front(key) {
|
||||
&sign.front
|
||||
} else {
|
||||
&sign.back
|
||||
};
|
||||
side.click
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|event| click_action(event).is_some())
|
||||
}
|
||||
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
|
||||
/// honeycomb): the item changes the side the player faces and is used up.
|
||||
/// Whether it did; a sign it does not apply to goes on to the empty hand.
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
//! `SignBlock.useWithoutItem`: the click it passes goes on to the item
|
||||
//! (`ServerPlayerGameMode.useItemOn`'s `useWithoutItem` then `ItemStack.useOn`),
|
||||
//! so an adventure player's `can_place_on` block places against the sign.
|
||||
use horizon_client::{
|
||||
command::args::GameMode, containers::BlockEntity, items::ItemStack, mobs::Who, signs::SignData,
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
type Posted = (&'static str, [f64; 3], Option<Who>);
|
||||
|
||||
/// A server's game with nobody playing, or `None` without prepared resources.
|
||||
fn game() -> Option<crate::game::Game<'static>> {
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")?;
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.player = None;
|
||||
Some(game)
|
||||
}
|
||||
|
||||
/// `stack` with a `can_place_on` component naming `block`.
|
||||
fn held(name: &str, count: u32, block: &str) -> ItemStack {
|
||||
let mut stack = ItemStack::new(name, count);
|
||||
stack.data_mut().other.push((
|
||||
"minecraft:can_place_on".into(),
|
||||
Tag::compound([("blocks", Tag::List(vec![Tag::String(block.into())]))]),
|
||||
));
|
||||
stack
|
||||
}
|
||||
|
||||
/// An adventure player (`mayBuild` false) clicks the south side of the sign
|
||||
/// at [2, 4, 3] with `stack` in hand, as `Minecraft.startUseItem` does; the
|
||||
/// game events the click posted.
|
||||
fn click_sign(game: &mut crate::game::Game<'_>, sign: SignData, stack: ItemStack) -> Vec<Posted> {
|
||||
let registry = game.pack.assets.registry;
|
||||
let sign_state = registry.default_state("minecraft:oak_sign").unwrap();
|
||||
game.world.set(2, 4, 3, sign_state).unwrap();
|
||||
game.world.set(2, 4, 4, game.res.facts().air).unwrap();
|
||||
game.world
|
||||
.block_entities
|
||||
.insert((2, 4, 3), BlockEntity::Sign(sign));
|
||||
game.set_mode(GameMode::Adventure);
|
||||
*game.held_mut() = Some(stack);
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
let hit = crate::game::Hit {
|
||||
pos: [2, 4, 3],
|
||||
face: [0, 0, 1],
|
||||
point: [2.5, 4.5, 3.9],
|
||||
};
|
||||
game.with_player_actor(|g| g.use_hand(Some(&hit))).unwrap();
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap())
|
||||
}
|
||||
|
||||
/// `SignBlock.useWithoutItem` answers PASS for a sign with no click action to
|
||||
/// run, that is not waxed and that a player who may not build aims at, so
|
||||
/// ServerPlayerGameMode.useItemOn goes on to `ItemStack.useOn`: a
|
||||
/// `can_place_on` block naming the sign places against it.
|
||||
#[test]
|
||||
fn an_adventure_click_on_a_sign_places_a_can_place_on_block() {
|
||||
let Some(mut game) = game() else {
|
||||
return;
|
||||
};
|
||||
let planks = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:oak_planks")
|
||||
.unwrap();
|
||||
|
||||
let events = click_sign(
|
||||
&mut game,
|
||||
SignData::default(),
|
||||
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
|
||||
);
|
||||
assert_eq!(game.world.block(2, 4, 4), planks, "placed against the sign");
|
||||
assert_eq!(game.held().map(|s| s.count), Some(1), "one plank is used");
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![("minecraft:block_place", [2.5, 4.5, 4.5], Some(Who::Player))]
|
||||
);
|
||||
|
||||
// `can_place_on` names another block: nothing places and nothing is used.
|
||||
let events = click_sign(
|
||||
&mut game,
|
||||
SignData::default(),
|
||||
held("minecraft:oak_planks", 2, "minecraft:stone"),
|
||||
);
|
||||
assert_ne!(game.world.block(2, 4, 4), planks, "nothing places");
|
||||
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
|
||||
assert!(events.is_empty(), "{events:?}");
|
||||
}
|
||||
|
||||
/// The signs whose `useWithoutItem` consumes the click keep it, so the
|
||||
/// `can_place_on` block is never placed: a waxed sign (its failed-interaction
|
||||
/// sound) and a sign whose side runs a click command.
|
||||
#[test]
|
||||
fn a_waxed_sign_or_a_click_command_keeps_the_adventure_click() {
|
||||
let Some(mut game) = game() else {
|
||||
return;
|
||||
};
|
||||
let planks = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:oak_planks")
|
||||
.unwrap();
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
|
||||
let waxed = SignData {
|
||||
waxed: true,
|
||||
..SignData::default()
|
||||
};
|
||||
click_sign(
|
||||
&mut game,
|
||||
waxed,
|
||||
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
|
||||
);
|
||||
assert_ne!(game.world.block(2, 4, 4), planks, "a waxed sign keeps it");
|
||||
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
|
||||
|
||||
// A `run_command` line runs, and the click ends there.
|
||||
let mut command = SignData {
|
||||
allow_op_features: true,
|
||||
..SignData::default()
|
||||
};
|
||||
command.front.click[0] = Some(Tag::compound([
|
||||
("action", Tag::String("run_command".into())),
|
||||
(
|
||||
"command",
|
||||
Tag::String("setblock 5 4 3 minecraft:stone".into()),
|
||||
),
|
||||
]));
|
||||
let events = click_sign(
|
||||
&mut game,
|
||||
command,
|
||||
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
|
||||
);
|
||||
assert_eq!(game.world.block(5, 4, 3), stone, "the click command ran");
|
||||
assert_ne!(game.world.block(2, 4, 4), planks, "the command keeps it");
|
||||
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
|
||||
assert!(
|
||||
!events.iter().any(|e| e.0 == "minecraft:block_place"),
|
||||
"{events:?}"
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,6 @@ impl Game<'_> {
|
||||
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
|
||||
let (knockback, _) = self.extra_knockback("minecraft:ender_dragon", scale);
|
||||
self.attack_effects(area, None, false, super::mace::Spared::Nothing, None);
|
||||
self.post_attack_dragon_burst();
|
||||
self.special_attack_slowdown(knockback);
|
||||
self.special_attack_exhaustion();
|
||||
}
|
||||
|
||||
@@ -1362,76 +1362,6 @@ mod tests {
|
||||
Some(game)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn portal_blast_sulfur_cube_uses_only_positive_cooldown() {
|
||||
use horizon_client::mobs::Mob;
|
||||
use horizon_client::nether_portal::PortalState;
|
||||
let mut game = server_game().expect("set HORIZON_TEST_RESOURCES");
|
||||
let portal = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:nether_portal")
|
||||
.unwrap();
|
||||
for (cooldown, silent) in [
|
||||
(1, false),
|
||||
(1, true),
|
||||
(0, false),
|
||||
(0, true),
|
||||
(-1, false),
|
||||
(-1, true),
|
||||
] {
|
||||
game.world.set(8, 64, 8, portal).unwrap();
|
||||
let kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:sulfur_cube")
|
||||
.unwrap()
|
||||
.clone();
|
||||
let mut cube = Mob::new(9123, kind, Vec3::new(8.5, 64.0, 8.5), 0.0);
|
||||
cube.silent = silent;
|
||||
cube.extra.body = Some("minecraft:tnt".into());
|
||||
cube.extra.counter2 = 2; // fuse 1: explodes on this tick.
|
||||
game.mobs.push(cube);
|
||||
game.portals.entities.insert(
|
||||
9123,
|
||||
PortalState {
|
||||
cooldown,
|
||||
process: None,
|
||||
},
|
||||
);
|
||||
// The actual level tick orders the portal pass before the mob pass.
|
||||
game.audio.heard.clear();
|
||||
game.handle_entity_portals();
|
||||
game.tick_creatures().unwrap();
|
||||
assert_eq!(
|
||||
game.world.block(8, 64, 8) == portal,
|
||||
cooldown > 0,
|
||||
"cooldown {cooldown}, Silent={silent}"
|
||||
);
|
||||
assert_eq!(
|
||||
game.audio
|
||||
.heard
|
||||
.get("minecraft:entity.generic.explode")
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
u32::from(!silent),
|
||||
"cooldown {cooldown}, Silent={silent}"
|
||||
);
|
||||
}
|
||||
// An unrelated native/sourceless blast never inherits the cube's calculator.
|
||||
game.world.set(8, 64, 8, portal).unwrap();
|
||||
game.server_explode(
|
||||
[8.5, 64.0625, 8.5],
|
||||
4.0,
|
||||
super::super::explosion::BlockInteraction::Destroy,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(game.world.block(8, 64, 8), portal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_tnt_fuse_uses_numeric_short_and_saves_without_raw() {
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
@@ -54,8 +54,6 @@ pub(super) struct ThrownTrident {
|
||||
pub(super) on_ground: bool,
|
||||
/// `Entity.remainingFireTicks`; on clients, the synchronized on-fire flag.
|
||||
pub(super) fire_ticks: i32,
|
||||
/// `Entity.fallDistance`: kept as loaded; water resets it.
|
||||
fall_distance: f64,
|
||||
/// `AbstractArrow.soundEvent`: the trident's own until it first hits a block.
|
||||
sound_event: String,
|
||||
/// Water membership from the preceding `baseTick` (not updated while stuck).
|
||||
@@ -131,7 +129,6 @@ impl ThrownTrident {
|
||||
self.no_gravity = n.no_gravity;
|
||||
self.on_ground = n.on_ground;
|
||||
self.fire_ticks = n.fire_ticks;
|
||||
self.fall_distance = n.fall_distance;
|
||||
self.sound_event = n.sound_event;
|
||||
}
|
||||
/// `AbstractArrow.addAdditionalSaveData` + `DealtDamage`.
|
||||
@@ -158,7 +155,6 @@ impl ThrownTrident {
|
||||
no_gravity: self.no_gravity,
|
||||
on_ground: self.on_ground,
|
||||
fire_ticks: self.fire_ticks,
|
||||
fall_distance: self.fall_distance,
|
||||
sound_event: &self.sound_event,
|
||||
item: &self.stack,
|
||||
},
|
||||
@@ -205,7 +201,6 @@ impl ThrownTrident {
|
||||
no_gravity: false,
|
||||
on_ground: false,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: horizon_client::entity_nbt::TRIDENT_HIT_GROUND.to_owned(),
|
||||
was_touching_water: false,
|
||||
random: horizon_core::random::LegacyRandom::new(i64::from(id)),
|
||||
@@ -625,7 +620,6 @@ impl Game<'_> {
|
||||
no_gravity: false,
|
||||
on_ground: false,
|
||||
fire_ticks: 0,
|
||||
fall_distance: 0.0,
|
||||
sound_event: horizon_client::entity_nbt::TRIDENT_HIT_GROUND.to_owned(),
|
||||
was_touching_water: false,
|
||||
random: horizon_core::random::LegacyRandom::new(i64::from(id)),
|
||||
@@ -1229,10 +1223,6 @@ impl Game<'_> {
|
||||
t.has_been_shot = true;
|
||||
let rain = self.arrow_rain();
|
||||
t.was_touching_water = self.arrow_scene(&rain).in_water(t.position);
|
||||
// Entity.updateInWaterStateAndDoFluidPushing: resetFallDistance in water.
|
||||
if t.was_touching_water {
|
||||
t.fall_distance = 0.0;
|
||||
}
|
||||
if t.fire_ticks > 0 {
|
||||
t.fire_ticks -= 1;
|
||||
}
|
||||
@@ -1524,27 +1514,6 @@ mod tests {
|
||||
assert_eq!(back.last_state, t.last_state);
|
||||
}
|
||||
|
||||
/// `Entity.saveWithoutId` writes the `fall_distance` `Entity.load` read:
|
||||
/// a trident keeps the one it was loaded with.
|
||||
#[test]
|
||||
fn a_trident_keeps_its_fall_distance_across_a_reload() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let registry = stone_registry();
|
||||
let fall = |tag: &Tag| match tag.get("fall_distance") {
|
||||
Some(Tag::Double(bits)) => Some(f64::from_bits(*bits)),
|
||||
_ => None,
|
||||
};
|
||||
let fresh = ThrownTrident::remote(1, Vec3::ZERO, 0.0, 0.0);
|
||||
assert_eq!(fall(&fresh.to_nbt([1; 4], ®istry, None)), Some(0.0));
|
||||
let tag = Tag::compound([
|
||||
("Pos", Tag::List(vec![Tag::Double(0); 3])),
|
||||
("fall_distance", Tag::Double(5.0_f64.to_bits())),
|
||||
]);
|
||||
let loaded = horizon_client::entity_nbt::trident_from_nbt(&tag).unwrap();
|
||||
let back = ThrownTrident::from_nbt(loaded, &tag, ®istry);
|
||||
assert_eq!(fall(&back.to_nbt([1; 4], ®istry, None)), Some(5.0));
|
||||
}
|
||||
|
||||
/// `AbstractArrow.startFalling` on `new Random(42)`: its first three
|
||||
/// `nextFloat() * 0.2F` draws (computed from Java's LCG) scale x, y, z.
|
||||
#[test]
|
||||
|
||||
@@ -127,10 +127,6 @@ impl Game<'_> {
|
||||
self.with_level(|b, _, _| b.cake_candle_state(cake, item.as_deref()).is_some());
|
||||
let pots_flower = self
|
||||
.with_level(|b, _, _| b.potted_flower_state(cake, item.as_deref()).is_some());
|
||||
// Read before the use runs: a sign's click commands execute in
|
||||
// `process_level_events` and may change or remove the sign, but
|
||||
// vanilla keeps the click consumed (`executedClickCommand`).
|
||||
let sign_consumes = self.sign_consumes_click(hit.pos);
|
||||
let (result, branch) = self.with_level(|b, level, ticks| {
|
||||
b.use_item_on_branch(
|
||||
level,
|
||||
@@ -182,15 +178,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// SignBlock.useWithoutItem answers PASS for a sign that is
|
||||
// not waxed, has no click action to run and that nobody may
|
||||
// build on: ServerPlayerGameMode.useItemOn goes on to
|
||||
// ItemStack.useOn, so an adventure-mode `can_place_on` stack
|
||||
// still places against the sign.
|
||||
let sign_passes = branch == horizon_world::level::UseBranch::WithoutItem
|
||||
&& self.res.facts().get(cake).sign_form().is_some()
|
||||
&& !sign_consumes;
|
||||
if result.consumes_action() && !sign_passes {
|
||||
if result.consumes_action() {
|
||||
// ServerPlayerGameMode.useItemOn: ITEM_USED_ON_BLOCK with
|
||||
// the stack before the use, or DEFAULT_BLOCK_USE.
|
||||
match branch {
|
||||
@@ -473,8 +461,6 @@ impl Game<'_> {
|
||||
return Ok(true);
|
||||
};
|
||||
let target = [pos.x, pos.y, pos.z];
|
||||
let game_master = self.can_use_game_master_blocks();
|
||||
let state = self.lectern_placed(target, state, &stack, game_master)?;
|
||||
let state = self.apply_item_block_state(target, state, &stack)?;
|
||||
let key = (target[0], target[1], target[2]);
|
||||
if !self.world.block_entities.stateful_entity(state.0)
|
||||
@@ -483,6 +469,7 @@ impl Game<'_> {
|
||||
self.world.block_entities.insert(key, entity);
|
||||
}
|
||||
if self.world.block_entities.get(&key).is_some() {
|
||||
let game_master = self.can_use_game_master_blocks();
|
||||
self.apply_placed_item(target, &stack, game_master);
|
||||
// Block.setPlacedBy.
|
||||
match self.world.block_entities.get(&key) {
|
||||
@@ -685,10 +672,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sign_use_tests.rs"]
|
||||
mod sign_use_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sign_chain_tests.rs"]
|
||||
mod sign_chain_tests;
|
||||
@@ -11,15 +11,10 @@ impl Game<'_> {
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return false;
|
||||
};
|
||||
// Consumable.canConsume asks the stack's own `food`.
|
||||
let consumable = self.consumable(&stack);
|
||||
if consumable.is_none()
|
||||
|| (horizon_client::item_sync::food(&stack, info.food).is_some()
|
||||
&& (self.player.is_none()
|
||||
|| !self.can_eat(
|
||||
&stack,
|
||||
horizon_client::item_sync::can_always_eat(&stack, info.can_always_eat),
|
||||
)))
|
||||
|| (info.food.is_some()
|
||||
&& (self.player.is_none() || !self.can_eat(&stack, info.can_always_eat)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -92,50 +92,6 @@ fn triggers(name: &str) -> bool {
|
||||
|| n == "bell"
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `Player.itemAttackInteraction` applies trigger-only held-item effects
|
||||
/// after an EnderDragonPart is successfully hit.
|
||||
pub(super) fn post_attack_dragon_burst(&mut self) {
|
||||
if let Some(held) = self.held().cloned() {
|
||||
self.post_attack_bursts(&held, "minecraft:ender_dragon");
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerExplosion.hurtEntities` knockback for a trigger-only blast such
|
||||
/// as Wind Burst. The dragon is not in the normal entity lists, but its
|
||||
/// body still receives the blast impulse (without taking damage).
|
||||
pub(super) fn wind_burst_knockback(
|
||||
&mut self,
|
||||
center: [f64; 3],
|
||||
radius: f32,
|
||||
multiplier: f32,
|
||||
blocks: &[[i32; 3]],
|
||||
) {
|
||||
let Some(d) = self.dragon.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let whole = d.whole_box();
|
||||
let (lo, hi) = (whole.min_position(), whole.max_position());
|
||||
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
|
||||
let Some((id, mut push)) = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.and_then(|d| Some((d.id, d.explosion_knockback(center, radius, exposure)?)))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for component in &mut push {
|
||||
*component *= f64::from(multiplier);
|
||||
}
|
||||
self.native_explosion_knockback(self.dim.id(), center, blocks, Some(id), &mut push);
|
||||
if let Some(d) = self.dragon.as_mut() {
|
||||
d.velocity = Vec3::new(
|
||||
d.velocity.x + push[0],
|
||||
d.velocity.y + push[1],
|
||||
d.velocity.z + push[2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `WindChargeItem.use`.
|
||||
pub(super) fn throw_wind_charge(&mut self) -> Result<bool> {
|
||||
let (Some(eye), Some(p)) = (self.eye_position(), self.player.as_ref()) else {
|
||||
@@ -616,7 +572,6 @@ impl Game<'_> {
|
||||
self.post_game_event("minecraft:explode", center, ctx);
|
||||
let mut targets = self.wind_positions(center, radius);
|
||||
let pushed = self.wind_knockback(center, radius, multiplier, breeze, wind_charge, &targets);
|
||||
self.wind_burst_knockback(center, radius, multiplier, &targets);
|
||||
let block_count = targets.len();
|
||||
// Explosion.canTriggerBlocks: a breeze's charge needs mob griefing.
|
||||
if !breeze || self.ui.rules.bool("mob_griefing") {
|
||||
@@ -785,10 +740,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[path = "wind_dragon_tests.rs"]
|
||||
mod wind_dragon_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn end_game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
assert_eq!(
|
||||
game.enter_command_level(crate::game::Dim::End),
|
||||
Some(crate::game::Dim::Overworld)
|
||||
);
|
||||
assert!(game.dimension_type().has_ender_dragon_fight, "the End");
|
||||
for x in -1..2 {
|
||||
for z in -1..2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_wind_burst_pushes_the_dragon_without_damage() {
|
||||
let mut game = end_game();
|
||||
game.spawn_dragon_spawned([0.0, 70.0, 0.0])
|
||||
.expect("spawned dragon");
|
||||
game.dragon.as_mut().expect("dragon").velocity = Vec3::ZERO;
|
||||
|
||||
game.wind_burst([0.0, 66.0, 0.0], 5.0, 1.0, false, false)
|
||||
.unwrap();
|
||||
|
||||
let dragon = game.dragon.as_ref().expect("dragon");
|
||||
assert_eq!(dragon.health, 200.0);
|
||||
assert!(
|
||||
dragon.velocity.y > 0.0,
|
||||
"Wind Burst did not push the dragon"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_wind_burst_scales_the_dragons_push_by_its_multiplier() {
|
||||
let mut game = end_game();
|
||||
game.spawn_dragon_spawned([0.0, 70.0, 0.0])
|
||||
.expect("spawned dragon");
|
||||
game.dragon.as_mut().expect("dragon").velocity = Vec3::ZERO;
|
||||
game.wind_burst([0.0, 66.0, 0.0], 5.0, 1.22, false, false)
|
||||
.unwrap();
|
||||
|
||||
let push = game.dragon.as_ref().expect("dragon").velocity.y;
|
||||
assert!(push > 0.0, "Wind Burst did not push the dragon");
|
||||
assert!(push > 0.6, "Wind Burst multiplier was not applied: {push}");
|
||||
}
|
||||
@@ -512,7 +512,6 @@ impl PlayerMotion {
|
||||
status: self.status.clone(),
|
||||
blocking,
|
||||
blocked: 0.0,
|
||||
blocking_item_used: false,
|
||||
fully_blocked: false,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
@@ -531,7 +530,6 @@ impl PlayerMotion {
|
||||
let outcome = BlockOutcome {
|
||||
hurt,
|
||||
blocked: environment.blocked,
|
||||
blocking_item_used: environment.blocking_item_used,
|
||||
fully_blocked: environment.fully_blocked,
|
||||
sound: environment.block_sound,
|
||||
knockback: environment.knocked,
|
||||
@@ -626,7 +624,6 @@ pub(crate) struct Environment<'a> {
|
||||
/// The raised blocking item, if any, and what it blocked.
|
||||
pub(crate) blocking: Option<Blocking>,
|
||||
pub(crate) blocked: f32,
|
||||
pub(crate) blocking_item_used: bool,
|
||||
/// What it blocked covers the whole hit, as difficulty scaled it.
|
||||
pub(crate) fully_blocked: bool,
|
||||
pub(crate) block_sound: bool,
|
||||
@@ -707,8 +704,6 @@ pub struct AttackRecord {
|
||||
/// The outcome of a hurt with a raised blocking item.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct BlockOutcome {
|
||||
/// `hurtBlockingItem` ran, including when reductions resolve to zero.
|
||||
pub blocking_item_used: bool,
|
||||
pub hurt: bool,
|
||||
pub blocked: f32,
|
||||
/// `applyItemBlocking`'s `damageBlocked >= damage`, against the damage
|
||||
@@ -943,7 +938,6 @@ impl HurtWorld for Environment<'_> {
|
||||
if damage <= 0.0 || b.bypassed || b.pierce {
|
||||
return Ok(0.0);
|
||||
}
|
||||
self.blocking_item_used = true;
|
||||
self.blocked = b.rules.resolve(&mut MatchedSets, damage, b.angle)?;
|
||||
self.fully_blocked = self.blocked >= damage;
|
||||
Ok(self.blocked)
|
||||
|
||||
@@ -17,7 +17,6 @@ pub struct EquipmentHurt {
|
||||
pending: Option<PendingHurt<()>>,
|
||||
inner: Option<bool>,
|
||||
blocked: f32,
|
||||
blocking_item_used: bool,
|
||||
fully_blocked: bool,
|
||||
block_sound: bool,
|
||||
knocked: bool,
|
||||
@@ -77,7 +76,6 @@ impl PlayerMotion {
|
||||
status: self.status.clone(),
|
||||
blocking: hit.blocking.clone(),
|
||||
blocked: hit.blocked,
|
||||
blocking_item_used: hit.blocking_item_used,
|
||||
fully_blocked: hit.fully_blocked,
|
||||
block_sound: hit.block_sound,
|
||||
knocked: hit.knocked,
|
||||
@@ -94,7 +92,6 @@ impl PlayerMotion {
|
||||
)?;
|
||||
let result = step(&mut h.hurt, &mut combat);
|
||||
hit.blocked = environment.blocked;
|
||||
hit.blocking_item_used = environment.blocking_item_used;
|
||||
hit.fully_blocked = environment.fully_blocked;
|
||||
hit.block_sound = environment.block_sound;
|
||||
hit.knocked = environment.knocked;
|
||||
@@ -126,7 +123,6 @@ impl PlayerMotion {
|
||||
pending: None,
|
||||
inner: None,
|
||||
blocked: 0.0,
|
||||
blocking_item_used: false,
|
||||
fully_blocked: false,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
@@ -223,7 +219,6 @@ impl PlayerMotion {
|
||||
Ok(BlockOutcome {
|
||||
hurt,
|
||||
blocked: hit.blocked,
|
||||
blocking_item_used: hit.blocking_item_used,
|
||||
fully_blocked: hit.fully_blocked,
|
||||
sound: hit.block_sound,
|
||||
knockback: hit.knocked,
|
||||
|
||||
@@ -183,7 +183,6 @@ impl FoodWorld for TickWorld<'_> {
|
||||
status: Default::default(),
|
||||
blocking: None,
|
||||
blocked: 0.0,
|
||||
blocking_item_used: false,
|
||||
fully_blocked: false,
|
||||
block_sound: false,
|
||||
knocked: false,
|
||||
|
||||
@@ -143,147 +143,6 @@ pub fn patched_use(stack: &ItemStack) -> (Option<i32>, bool) {
|
||||
out
|
||||
}
|
||||
|
||||
/// `FoodProperties.canAlwaysEat` from the stack's `food` component.
|
||||
pub fn can_always_eat(stack: &ItemStack, default: bool) -> bool {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:food").is_some()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(food) = data.and_then(|d| d.other("minecraft:food")) {
|
||||
return horizon_world::datafix::dynamic::as_bool(food.get("can_always_eat"), false);
|
||||
}
|
||||
default
|
||||
}
|
||||
/// `Consumable.consumeSeconds` from the stack's `consumable` component.
|
||||
pub fn consumable_seconds(stack: &ItemStack) -> Option<f32> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
|
||||
return None;
|
||||
}
|
||||
data.and_then(|d| d.other("minecraft:consumable"))
|
||||
.map(|consumable| {
|
||||
horizon_world::datafix::dynamic::as_float(consumable.get("consume_seconds"), 1.6)
|
||||
})
|
||||
}
|
||||
/// `UseRemainder` from the stack's `use_remainder` component.
|
||||
pub fn use_remainder(stack: &ItemStack, default: Option<String>) -> Option<String> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:use_remainder").is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(remainder) = data.and_then(|d| d.other("minecraft:use_remainder")) {
|
||||
return match remainder.get("id") {
|
||||
Some(Tag::String(id)) => Some(id.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
default
|
||||
}
|
||||
/// `Equippable.dispensable` from the stack's `equippable` component.
|
||||
pub fn dispensable(stack: &ItemStack, default: bool) -> bool {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
|
||||
return false;
|
||||
}
|
||||
if let Some(equippable) = data.and_then(|d| d.other("minecraft:equippable")) {
|
||||
return horizon_world::datafix::dynamic::as_bool(equippable.get("dispensable"), true);
|
||||
}
|
||||
default
|
||||
}
|
||||
/// `FoodProperties.nutrition` and `saturation` from the stack's `food`
|
||||
/// component, else the item's.
|
||||
pub fn food(stack: &ItemStack, default: Option<(u32, f32)>) -> Option<(u32, f32)> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:food").is_some()) {
|
||||
return None;
|
||||
}
|
||||
if let Some(food) = data.and_then(|d| d.other("minecraft:food")) {
|
||||
return Some((
|
||||
horizon_world::datafix::dynamic::as_int(food.get("nutrition"), 0).max(0) as u32,
|
||||
horizon_world::datafix::dynamic::as_float(food.get("saturation"), 0.0),
|
||||
));
|
||||
}
|
||||
default
|
||||
}
|
||||
/// `Consumable.animation` from the stack's `consumable` component.
|
||||
pub fn consumable_animation(stack: &ItemStack) -> Option<horizon_world::consumable::UseAnimation> {
|
||||
use horizon_world::consumable::UseAnimation;
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
|
||||
return None;
|
||||
}
|
||||
let Some(Tag::String(animation)) = data
|
||||
.and_then(|d| d.other("minecraft:consumable"))
|
||||
.and_then(|c| c.get("animation"))
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
// ItemUseAnimation.CODEC's names.
|
||||
Some(match &*animation.to_string_lossy() {
|
||||
"none" => UseAnimation::None,
|
||||
"drink" => UseAnimation::Drink,
|
||||
"block" => UseAnimation::Block,
|
||||
"bow" => UseAnimation::Bow,
|
||||
"trident" => UseAnimation::Trident,
|
||||
"crossbow" => UseAnimation::Crossbow,
|
||||
"spyglass" => UseAnimation::Spyglass,
|
||||
"toot_horn" => UseAnimation::TootHorn,
|
||||
"brush" => UseAnimation::Brush,
|
||||
"bundle" => UseAnimation::Bundle,
|
||||
"spear" => UseAnimation::Spear,
|
||||
_ => UseAnimation::Eat,
|
||||
})
|
||||
}
|
||||
/// `Consumable.sound` from the stack's `consumable` component.
|
||||
pub fn consumable_sound(stack: &ItemStack) -> Option<String> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
|
||||
return None;
|
||||
}
|
||||
let sound = data
|
||||
.and_then(|d| d.other("minecraft:consumable"))
|
||||
.and_then(|c| c.get("sound"))?;
|
||||
match sound {
|
||||
Tag::String(id) => Some(id.to_string_lossy()),
|
||||
// SoundEvent.DIRECT_CODEC: `{sound_id, range}`.
|
||||
Tag::Compound(_) => match sound.get("sound_id") {
|
||||
Some(Tag::String(id)) => Some(id.to_string_lossy()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `Consumable.hasConsumeParticles` from the stack's `consumable` component.
|
||||
pub fn consumable_particles(stack: &ItemStack) -> Option<bool> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
|
||||
return None;
|
||||
}
|
||||
data.and_then(|d| d.other("minecraft:consumable"))
|
||||
.map(|c| horizon_world::datafix::dynamic::as_bool(c.get("has_consume_particles"), true))
|
||||
}
|
||||
/// The `consumable` component a use reads: the stack's own when it has one
|
||||
/// (its time, animation, sound and particles, with `Consumable.CODEC`'s
|
||||
/// defaults for what it leaves out), else the item's. The item's effects stay:
|
||||
/// a stack's `on_consume_effects` are not read.
|
||||
pub fn stack_consumable<'a, E: Clone>(
|
||||
stack: &ItemStack,
|
||||
item: &'a horizon_world::consumable::Consumable<String, E>,
|
||||
) -> std::borrow::Cow<'a, horizon_world::consumable::Consumable<String, E>> {
|
||||
let data = stack.data();
|
||||
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some())
|
||||
|| !data.is_some_and(|d| d.other("minecraft:consumable").is_some())
|
||||
{
|
||||
return std::borrow::Cow::Borrowed(item);
|
||||
}
|
||||
let mut c = item.clone();
|
||||
c.seconds = consumable_seconds(stack).unwrap_or(1.6);
|
||||
c.animation =
|
||||
consumable_animation(stack).unwrap_or(horizon_world::consumable::UseAnimation::Eat);
|
||||
c.sound = consumable_sound(stack).unwrap_or_else(|| "minecraft:entity.generic.eat".into());
|
||||
c.particles = consumable_particles(stack).unwrap_or(true);
|
||||
std::borrow::Cow::Owned(c)
|
||||
}
|
||||
/// The parts of a stack's `equippable` component (`Equippable`) the client
|
||||
/// draws with.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -1568,188 +1427,6 @@ mod tests {
|
||||
assert_eq!(net_c.effects, effects);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn food_can_always_eat_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:food",
|
||||
Some(Tag::compound([("can_always_eat", Tag::Byte(1))])),
|
||||
);
|
||||
assert!(can_always_eat(&stack, false));
|
||||
assert!(!can_always_eat(
|
||||
&ItemStack::new("minecraft:apple", 1),
|
||||
false
|
||||
));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:food",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(!can_always_eat(&stack, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_seconds_come_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([(
|
||||
"consume_seconds",
|
||||
Tag::Float(0.5f32.to_bits()),
|
||||
)])),
|
||||
);
|
||||
assert_eq!(consumable_seconds(&stack), Some(0.5));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:consumable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(consumable_seconds(&stack), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_remainder_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:mushroom_stew", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:use_remainder",
|
||||
Some(Tag::compound([(
|
||||
"id",
|
||||
Tag::String("minecraft:bowl".into()),
|
||||
)])),
|
||||
);
|
||||
assert_eq!(
|
||||
use_remainder(&stack, None).as_deref(),
|
||||
Some("minecraft:bowl")
|
||||
);
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:use_remainder",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(
|
||||
use_remainder(&stack, Some("minecraft:glass_bottle".into())),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispensable_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:golden_helmet", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:equippable",
|
||||
Some(Tag::compound([
|
||||
("slot", Tag::String("head".into())),
|
||||
("dispensable", Tag::Byte(0)),
|
||||
])),
|
||||
);
|
||||
assert!(!dispensable(&stack, true));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:equippable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert!(!dispensable(&stack, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn food_nutrition_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:food",
|
||||
Some(Tag::compound([
|
||||
("nutrition", Tag::Int(9)),
|
||||
("saturation", Tag::Float(4.5f32.to_bits())),
|
||||
])),
|
||||
);
|
||||
assert_eq!(food(&stack, Some((4, 2.4))), Some((9, 4.5)));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:food",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(food(&stack, Some((4, 2.4))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_animation_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([("animation", Tag::String("drink".into()))])),
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_animation(&stack),
|
||||
Some(horizon_world::consumable::UseAnimation::Drink)
|
||||
);
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:consumable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(consumable_animation(&stack), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_sound_comes_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([(
|
||||
"sound",
|
||||
Tag::String("minecraft:entity.generic.drink".into()),
|
||||
)])),
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_sound(&stack).as_deref(),
|
||||
Some("minecraft:entity.generic.drink")
|
||||
);
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:consumable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(consumable_sound(&stack), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_particles_come_from_the_stack() {
|
||||
let mut stack = ItemStack::new("minecraft:apple", 1);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([("has_consume_particles", Tag::Byte(0))])),
|
||||
);
|
||||
assert_eq!(consumable_particles(&stack), Some(false));
|
||||
stack.data_mut().set_other(
|
||||
"!minecraft:consumable",
|
||||
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
|
||||
);
|
||||
assert_eq!(consumable_particles(&stack), None);
|
||||
}
|
||||
|
||||
/// A stack's own `consumable` replaces the item's: what it leaves out
|
||||
/// takes the codec's defaults, not the item's values.
|
||||
#[test]
|
||||
fn a_stacks_consumable_takes_the_codecs_defaults() {
|
||||
use horizon_world::consumable::{Consumable, UseAnimation};
|
||||
let honey: Consumable<String> = Consumable {
|
||||
seconds: 2.0,
|
||||
animation: UseAnimation::Drink,
|
||||
sound: "minecraft:item.honey_bottle.drink".into(),
|
||||
particles: false,
|
||||
effects: vec![],
|
||||
};
|
||||
let mut stack = ItemStack::new("minecraft:honey_bottle", 1);
|
||||
assert_eq!(*stack_consumable(&stack, &honey), honey);
|
||||
stack.data_mut().set_other(
|
||||
"minecraft:consumable",
|
||||
Some(Tag::compound([(
|
||||
"sound",
|
||||
Tag::compound([(
|
||||
"sound_id",
|
||||
Tag::String("minecraft:entity.generic.drink".into()),
|
||||
)]),
|
||||
)])),
|
||||
);
|
||||
let c = stack_consumable(&stack, &honey);
|
||||
assert_eq!(c.seconds, 1.6);
|
||||
assert_eq!(c.animation, UseAnimation::Eat);
|
||||
assert_eq!(c.sound, "minecraft:entity.generic.drink");
|
||||
assert!(c.particles);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merchant_water_cost_survives_the_offer_packet() {
|
||||
use horizon_protocol::{
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
//! The converted equipment of one guest. A slot keeps the stack it was converted from; the
|
||||
//! stack is converted again only when the slot holds a different one, compared in full.
|
||||
|
||||
use crate::items::ItemStack;
|
||||
use horizon_protocol::item::Stack;
|
||||
|
||||
/// Equipment slots: held, offhand, then the armor from head to feet.
|
||||
pub const SLOTS: usize = 6;
|
||||
|
||||
/// The wire slot of each equipment slot, in the order the equipment is listed. Armor is stored
|
||||
/// head..feet; the wire slots are feet(2) legs(3) chest(4) head(5).
|
||||
const WIRE_SLOTS: [u8; SLOTS] = [0, 1, 5, 4, 3, 2];
|
||||
|
||||
#[derive(Default)]
|
||||
struct Slot {
|
||||
source: Option<ItemStack>,
|
||||
converted: Option<Stack>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EquipmentCache {
|
||||
slots: [Slot; SLOTS],
|
||||
}
|
||||
|
||||
impl EquipmentCache {
|
||||
/// Whether `source` differs from the stack slot `index` was last converted from.
|
||||
pub fn is_stale(&self, index: usize, source: Option<&ItemStack>) -> bool {
|
||||
self.slots[index].source.as_ref() != source
|
||||
}
|
||||
|
||||
/// Records the stack slot `index` was converted from, and the conversion.
|
||||
pub fn store(&mut self, index: usize, source: Option<ItemStack>, converted: Option<Stack>) {
|
||||
self.slots[index] = Slot { source, converted };
|
||||
}
|
||||
|
||||
/// `(wire slot, stack)` for each equipment slot that holds an item.
|
||||
pub fn equipment(&self) -> Vec<(u8, Option<Stack>)> {
|
||||
WIRE_SLOTS
|
||||
.iter()
|
||||
.zip(&self.slots)
|
||||
.filter(|(_, slot)| slot.source.is_some())
|
||||
.map(|(&wire, slot)| (wire, slot.converted.clone()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -301,8 +301,6 @@ pub struct Guest {
|
||||
/// with `armor` so each player compares against their own snapshot.
|
||||
pub location_armor: [Option<ItemStack>; 4],
|
||||
pub offhand: Option<ItemStack>,
|
||||
/// The converted held, offhand and armor stacks, kept while they do not change.
|
||||
pub equipment: super::equipment_cache::EquipmentCache,
|
||||
pub selected: usize,
|
||||
pub carried: Option<ItemStack>,
|
||||
/// The inventory menu's crafting grid (2x2) and its result
|
||||
@@ -559,7 +557,6 @@ impl Guest {
|
||||
armor: [None, None, None, None],
|
||||
location_armor: [None, None, None, None],
|
||||
offhand: None,
|
||||
equipment: Default::default(),
|
||||
selected: 0,
|
||||
carried: None,
|
||||
craft_grid: Default::default(),
|
||||
|
||||
@@ -9,7 +9,6 @@ use super::{
|
||||
chunks::ChunkEncoder,
|
||||
data::SyncData,
|
||||
entity::{Entities, EntityState, Viewer},
|
||||
equipment_cache,
|
||||
guest::{Guest, SentEffect, stack_to_network},
|
||||
};
|
||||
use crate::{
|
||||
@@ -3280,19 +3279,28 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
/// The equipment of a placed guest; only the stacks that changed since the last call are converted.
|
||||
fn guest_equipment(&mut self, i: usize) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
|
||||
for index in 0..equipment_cache::SLOTS {
|
||||
let g = &self.guests[i];
|
||||
let source = equipment_source(g, index);
|
||||
if !g.equipment.is_stale(index, source) {
|
||||
continue;
|
||||
fn equipment_of(
|
||||
&mut self,
|
||||
main: &Option<ItemStack>,
|
||||
off: &Option<ItemStack>,
|
||||
armor: [&Option<ItemStack>; 4],
|
||||
) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
|
||||
// Armor is stored head..feet; the wire slots are feet(2) legs(3) chest(4) head(5).
|
||||
let mut out = Vec::new();
|
||||
let slots = [
|
||||
(0u8, main),
|
||||
(1, off),
|
||||
(5, armor[0]),
|
||||
(4, armor[1]),
|
||||
(3, armor[2]),
|
||||
(2, armor[3]),
|
||||
];
|
||||
for (slot, item) in slots {
|
||||
if item.is_some() {
|
||||
out.push((slot, self.stack(item)));
|
||||
}
|
||||
let source = source.cloned();
|
||||
let converted = self.stack(&source);
|
||||
self.guests[i].equipment.store(index, source, converted);
|
||||
}
|
||||
self.guests[i].equipment.equipment()
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether an entity is tracked by the current dimension's level.
|
||||
@@ -3371,7 +3379,16 @@ impl Hub {
|
||||
let guest_states: Vec<_> = placed
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let equipment = self.guest_equipment(i);
|
||||
let (main, off, armor) = {
|
||||
let g = &self.guests[i];
|
||||
(
|
||||
g.inventory.get(g.selected).cloned().flatten(),
|
||||
g.offhand.clone(),
|
||||
g.armor.clone(),
|
||||
)
|
||||
};
|
||||
let equipment =
|
||||
self.equipment_of(&main, &off, [&armor[0], &armor[1], &armor[2], &armor[3]]);
|
||||
let g = &self.guests[i];
|
||||
let mut state = self.player_state(
|
||||
g.entity_id,
|
||||
@@ -3735,8 +3752,6 @@ impl Hub {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as i64);
|
||||
for g in &mut self.guests {
|
||||
// Converted under the old registries: convert again under the new ones.
|
||||
g.equipment = Default::default();
|
||||
let mut granted = Vec::new();
|
||||
// PlayerAdvancements.reload: isFirstPacket again, the progress
|
||||
// read against the new tree; a player whose advancements were
|
||||
@@ -5251,15 +5266,6 @@ fn listed_properties(
|
||||
}
|
||||
}
|
||||
|
||||
/// The stack in equipment slot `index` (`equipment_cache::SLOTS`): held, offhand, armor head..feet.
|
||||
fn equipment_source(g: &Guest, index: usize) -> Option<&ItemStack> {
|
||||
match index {
|
||||
0 => g.inventory.get(g.selected)?.as_ref(),
|
||||
1 => g.offhand.as_ref(),
|
||||
_ => g.armor.get(index - 2)?.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hub_tests.rs"]
|
||||
mod tests;
|
||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user