mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Refresh reinforcement command fix against main
Keep reinforcement and advancement-loot corpus groups, all diagnostic options, and light/player observations. The reinforcement fixture function is byte-identical. Formatting, all-target Clippy, Python compilation/options/registration and Java oracle compilation pass. Original runtime proof remains qualified to its captured base. Co-Authored-By: Codex <[email protected]>
This commit is contained in:
commit
f60a109738
335 files changed
+15210
-966
No files matched your search
@@ -158,7 +158,7 @@ jobs:
|
||||
run: |
|
||||
cargo +stable fmt --check
|
||||
cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
cargo +stable test --locked --lib
|
||||
cargo +stable test --locked --lib --bins
|
||||
# Compile and test Windows ARM64 on an actual ARM64 host, and inspect the resulting PE headers. By hand only
|
||||
# (`gh workflow run checks --ref <branch>`): GitHub's runner queue is the bottleneck, and main's runs gate the
|
||||
# releases (tools/release_train.py waits for them), so neither pull requests nor main's pushes start this
|
||||
|
||||
@@ -83,10 +83,10 @@ jobs:
|
||||
build:
|
||||
needs: exporters
|
||||
name: build ${{ matrix.name }}
|
||||
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
|
||||
# Windows ARM64 and musl are new: a failure never holds the other platforms' release (the signing job
|
||||
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
|
||||
# ARM64 files out with a warning.
|
||||
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
|
||||
# optional archives out with a warning.
|
||||
continue-on-error: ${{ matrix.name == 'windows-arm64' || matrix.name == 'linux-musl' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -98,6 +98,11 @@ jobs:
|
||||
# The server build too: packaging/server/install.sh installs it on Linux.
|
||||
server_dir: horizon-server-linux-x86_64
|
||||
server_asset: horizon-server-linux-x86_64.tar.gz
|
||||
- name: linux-musl
|
||||
os: ubuntu-22.04
|
||||
musl: true
|
||||
dir: horizon-linux-musl-x86_64
|
||||
asset: horizon-linux-musl-x86_64.tar.gz
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
dir: horizon-windows-x64
|
||||
@@ -155,17 +160,19 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
if: runner.environment == 'github-hosted'
|
||||
if: runner.environment == 'github-hosted' && !matrix.musl
|
||||
with:
|
||||
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
|
||||
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
|
||||
save-if: ${{ inputs.trusted }}
|
||||
- name: ALSA and udev headers
|
||||
if: runner.os == 'Linux'
|
||||
if: runner.os == 'Linux' && !matrix.musl
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
if: ${{ !matrix.musl }}
|
||||
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
if: ${{ !matrix.musl }}
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
@@ -193,6 +200,7 @@ jobs:
|
||||
codesign --verify --strict --verbose=2 "$BIN/$binary"
|
||||
done
|
||||
- name: Only system libraries
|
||||
if: ${{ !matrix.musl }}
|
||||
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
|
||||
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
|
||||
run: |
|
||||
@@ -208,13 +216,14 @@ jobs:
|
||||
- name: Baseline x86-64 CPUs only
|
||||
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
|
||||
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
|
||||
if: runner.os != 'macOS' && matrix.pe_arch != 'arm64'
|
||||
if: runner.os != 'macOS' && matrix.pe_arch != 'arm64' && !matrix.musl
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup component add llvm-tools
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_cpu_baseline.py \
|
||||
$(for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do echo "$BIN/$binary${{ matrix.exe }}"; done)
|
||||
- name: Every program starts
|
||||
if: ${{ !matrix.musl }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bin=$BIN
|
||||
@@ -270,7 +279,15 @@ jobs:
|
||||
with:
|
||||
name: original-exporters
|
||||
path: exporters
|
||||
- name: Build, check and package for musl in Alpine
|
||||
if: matrix.musl
|
||||
# Run Docker on the Ubuntu host: JavaScript actions themselves require glibc.
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/work" -w /work \
|
||||
-e HORIZON_VERSION -e HORIZON_NIGHTLY \
|
||||
rust:alpine3.22 sh tools/ci/build-musl.sh
|
||||
- name: Package
|
||||
if: ${{ !matrix.musl }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
|
||||
|
||||
@@ -174,8 +174,10 @@ jobs:
|
||||
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
|
||||
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
|
||||
else echo "::warning::horizon-windows-arm64.zip is missing: SHA256SUMS goes out without it"; fi
|
||||
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
|
||||
else echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: SHA256SUMS goes out without it"; fi
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
"${arm64[@]}" \
|
||||
"${arm64[@]}" "${musl[@]}" \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v7
|
||||
@@ -241,11 +243,18 @@ jobs:
|
||||
else
|
||||
echo "::warning::horizon-windows-arm64.zip is missing: the Nightly goes out without it (ARM64 launchers install the x64 build)"
|
||||
fi
|
||||
musl=()
|
||||
if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then
|
||||
musl=("dist/horizon-linux-musl-x86_64.tar.gz#Game files for musl Linux (horizon-linux-musl-x86_64.tar.gz)")
|
||||
else
|
||||
echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: the Nightly goes out without it"
|
||||
fi
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
|
||||
"${musl[@]}" \
|
||||
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
|
||||
"${arm64[@]}" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
|
||||
@@ -407,6 +407,16 @@ impl BlockEntities {
|
||||
self.add(pos);
|
||||
}
|
||||
}
|
||||
/// [`Self::retain`] for unloading chunks: removes the block entities at
|
||||
/// the positions `gone` names and changes none it keeps, so those that
|
||||
/// sleep stay asleep (a moving player unloads chunks most ticks).
|
||||
pub fn unload(&mut self, mut gone: impl FnMut(&Pos) -> bool) {
|
||||
let asleep = std::mem::take(&mut self.asleep);
|
||||
self.retain(|p, _| !gone(p));
|
||||
self.asleep = asleep;
|
||||
let map = &self.map;
|
||||
self.asleep.retain(|p| map.contains_key(p));
|
||||
}
|
||||
/// Removes the block entities `keep` refuses, and their slots
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
@@ -558,6 +568,24 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Unloading a chunk removes its block entities and leaves the others'
|
||||
/// sleep alone; `retain`, which may change what it keeps, wakes them all.
|
||||
#[test]
|
||||
fn unloading_a_chunk_keeps_the_others_asleep() {
|
||||
let mut b = level();
|
||||
let (kept, gone) = ((1, 64, 1), (17, 64, 1));
|
||||
b.insert(kept, hopper());
|
||||
b.insert(gone, hopper());
|
||||
b.sleep(kept);
|
||||
b.sleep(gone);
|
||||
b.unload(|p| p.0 >> 4 == 1);
|
||||
assert!(b.get(&gone).is_none());
|
||||
assert!(!b.is_asleep(&gone));
|
||||
assert!(b.is_asleep(&kept));
|
||||
b.retain(|_, _| true);
|
||||
assert!(!b.is_asleep(&kept));
|
||||
}
|
||||
|
||||
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
|
||||
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
|
||||
found.sort();
|
||||
|
||||
@@ -487,6 +487,63 @@ mod tests {
|
||||
assert!(!stand.idle());
|
||||
}
|
||||
|
||||
/// The stand sleeps while `idle`, so its tick must change nothing then: run the real
|
||||
/// tick on idle stands and compare, and check a stand with fuel to take wakes.
|
||||
#[test]
|
||||
fn an_idle_brewing_stand_tick_changes_nothing() {
|
||||
let items = Items::for_tests(std::collections::HashMap::new());
|
||||
let brewing = Brewing {
|
||||
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
inputs: HashSet::new(),
|
||||
recipes: vec![BrewingRecipe {
|
||||
id: "awkward".into(),
|
||||
input: PotionIngredient {
|
||||
items: ["minecraft:potion".to_owned()].into_iter().collect(),
|
||||
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
|
||||
},
|
||||
reagent: PotionIngredient {
|
||||
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
potions: None,
|
||||
},
|
||||
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
|
||||
}],
|
||||
};
|
||||
let providers = Providers::empty();
|
||||
let water = || Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
|
||||
let stand = |bottle: bool, fuel: i32, powder: bool| {
|
||||
let mut s = BrewingStand::new();
|
||||
s.fuel = fuel;
|
||||
if bottle {
|
||||
s.items[1] = water();
|
||||
}
|
||||
if powder {
|
||||
s.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
|
||||
}
|
||||
s.last_bottles = Some([false, bottle, false]);
|
||||
s
|
||||
};
|
||||
for before in [
|
||||
stand(false, 0, false),
|
||||
stand(true, 0, false),
|
||||
stand(true, 5, false),
|
||||
stand(true, 5, true),
|
||||
] {
|
||||
assert!(before.idle(), "{before:?}");
|
||||
let mut after = before.clone();
|
||||
assert_eq!(
|
||||
after.tick(&brewing, &items, &providers),
|
||||
BrewTick::default()
|
||||
);
|
||||
assert_eq!(format!("{after:?}"), format!("{before:?}"));
|
||||
}
|
||||
// A brewable ingredient starts the brew: not idle, and the tick changes it.
|
||||
let mut brewing_now = stand(true, 5, false);
|
||||
brewing_now.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
|
||||
assert!(!brewing_now.idle());
|
||||
brewing_now.tick(&brewing, &items, &providers);
|
||||
assert_eq!(brewing_now.brew_time, 400);
|
||||
}
|
||||
|
||||
/// #3312: `PotionContents.getPotionDescription` names the effects with
|
||||
/// translatable components, so a Russian game reads them from ru_ru.json
|
||||
/// (the prepared one; skipped without it), as the tooltip's other lines.
|
||||
|
||||
@@ -749,6 +749,43 @@ mod tests {
|
||||
assert!(c.idle(false));
|
||||
}
|
||||
|
||||
/// The campfire sleeps while `idle`, so the tick its block runs (`cook_tick` lit,
|
||||
/// `cooldown_tick` unlit) must change nothing then, and must change an awake one.
|
||||
#[test]
|
||||
fn an_idle_campfire_tick_changes_nothing() {
|
||||
let tick = |c: &mut Campfire, lit: bool| {
|
||||
if lit {
|
||||
!c.cook_tick().is_empty()
|
||||
} else {
|
||||
c.cooldown_tick()
|
||||
}
|
||||
};
|
||||
let empty = Campfire::default();
|
||||
let mut food = Campfire::default();
|
||||
assert!(food.place_food(&mut ItemStack::new("minecraft:beef", 1), 600));
|
||||
let mut warm = food.clone();
|
||||
warm.cook_tick();
|
||||
let mut cooled = warm.clone();
|
||||
while cooled.cooldown_tick() {}
|
||||
for (before, lit) in [
|
||||
(&empty, true),
|
||||
(&empty, false),
|
||||
(&food, false),
|
||||
(&cooled, false),
|
||||
] {
|
||||
assert!(before.idle(lit), "{before:?} lit {lit}");
|
||||
let mut after = before.clone();
|
||||
assert!(!tick(&mut after, lit));
|
||||
assert_eq!(&after, before, "lit {lit}");
|
||||
}
|
||||
for (before, lit) in [(&food, true), (&warm, false)] {
|
||||
assert!(!before.idle(lit), "{before:?} lit {lit}");
|
||||
let mut after = before.clone();
|
||||
tick(&mut after, lit);
|
||||
assert_ne!(&after, before, "lit {lit}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
|
||||
@@ -936,6 +936,10 @@ impl Font {
|
||||
offset: 0.5,
|
||||
})
|
||||
}
|
||||
/// Starts the random `random_glyph` draws on over from `seed`.
|
||||
pub fn reseed(&self, seed: i64) {
|
||||
self.cache.borrow_mut().random = horizon_core::random::LegacyRandom::new(seed);
|
||||
}
|
||||
/// `FontSet.getRandomGlyph`: what an obfuscated character draws, a random
|
||||
/// glyph of the font as wide as `advance` (rounded up), else the missing
|
||||
/// glyph.
|
||||
|
||||
@@ -50,11 +50,13 @@ mod inter_stage_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_gpu;
|
||||
mod weather;
|
||||
mod world_text;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, mark_oit,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
@@ -1092,10 +1094,11 @@ pub struct Renderer {
|
||||
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
|
||||
sides_on: bool,
|
||||
/// The near solid sections culled and drawn on the GPU (`gpu_culling`): its pass,
|
||||
/// records and fades while it is on, where the device can (`gpu_culling_supported`),
|
||||
/// and whether the last frame drew them so (`gpu_frame`).
|
||||
/// records and fades while it is on, where it is offered (`gpu_culling_offer`, mirrored
|
||||
/// for the settings in `graphics::gpu_culling_offer`), and whether the last frame drew
|
||||
/// them so (`gpu_frame`).
|
||||
gpu_culling: Option<gpu_culling::GpuCulling>,
|
||||
gpu_culling_supported: bool,
|
||||
gpu_culling_offer: crate::graphics::GpuCullingOffer,
|
||||
gpu_frame: bool,
|
||||
/// GPU frames since GPU culling was switched on: the first and every 64th after it
|
||||
/// check its records (`check_gpu_records`).
|
||||
@@ -1167,6 +1170,7 @@ pub struct Renderer {
|
||||
/// Native mods' world primitives (`Frame::world_primitives`).
|
||||
world_primitives: world_primitives::WorldPrimitives,
|
||||
text_polygon_offset_pipeline: wgpu::RenderPipeline,
|
||||
world_text_pipeline: wgpu::RenderPipeline,
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
@@ -1391,6 +1395,8 @@ pub struct Renderer {
|
||||
pack_terrain_flat: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
|
||||
pack_lava_layers: Vec<u16>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -1408,11 +1414,17 @@ pub struct Renderer {
|
||||
/// panics when such a surface is acquired from (it never had one that
|
||||
/// worked), so frames configure it again first and skip until it takes.
|
||||
surface_unconfigured: bool,
|
||||
/// The last frame's statistics, all of one frame (`show_stats`): its sections drawn
|
||||
/// (F3's `C:`, the world icon's check), section draws (face ranges and translucent
|
||||
/// sections) and the draw calls the backend issued for them. A CPU frame's are its own;
|
||||
/// in GPU frames (`gpu_culling`) they are the newest GPU frame's whose counts came back,
|
||||
/// a frame or a few earlier (`stats_lag`).
|
||||
pub drawn_sections: usize,
|
||||
/// Last frame's section draws (face ranges and translucent sections),
|
||||
/// and the draw calls the backend issued for them.
|
||||
pub section_draws: usize,
|
||||
pub section_draw_calls: usize,
|
||||
/// `draw_frame`'s frames past its early returns, and the one whose statistics are shown.
|
||||
frame_serial: u64,
|
||||
stats_frame: u64,
|
||||
pub uploaded_bytes: u64,
|
||||
started: Instant,
|
||||
/// The next frame to write to a PNG path (control socket, F2, world icon).
|
||||
@@ -3731,6 +3743,12 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let world_text_pipeline = world_text::pipeline(
|
||||
&device,
|
||||
Some(&entity_layout),
|
||||
&entity_shader,
|
||||
config.format.remove_srgb_suffix(),
|
||||
);
|
||||
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
|
||||
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
|
||||
// ground it is stopped by, #5950). Terrain and entities are separate draw
|
||||
@@ -4374,14 +4392,19 @@ impl Renderer {
|
||||
let batchable = translucent_batchable(&adapter.get_info(), multi_draw);
|
||||
crate::graphics::offer_translucent_batching(batchable);
|
||||
let translucent_batching = batchable && translucent_batching;
|
||||
let gpu_culling_supported = gpu_culling::supported(
|
||||
let gpu_culling_offer = gpu_culling::offer(
|
||||
&adapter.get_info(),
|
||||
device.features(),
|
||||
adapter.get_downlevel_capabilities().flags,
|
||||
&device.limits(),
|
||||
multi_draw,
|
||||
flat.as_ref().map(|f| f.min_sections),
|
||||
gpu_culling::supported(
|
||||
&adapter.get_info(),
|
||||
device.features(),
|
||||
adapter.get_downlevel_capabilities().flags,
|
||||
&device.limits(),
|
||||
multi_draw,
|
||||
flat.as_ref().map(|f| f.min_sections),
|
||||
),
|
||||
flat.is_some(),
|
||||
);
|
||||
crate::graphics::offer_gpu_culling(gpu_culling_offer);
|
||||
// A new renderer runs no shader pack: the game loads the picked one on its
|
||||
// first world frame (`shader_pack`: the picked pack draws flat, a `shaders/`
|
||||
// pack or one of Horizon's own with `[shadow]`), and the frames before it, the
|
||||
@@ -4455,7 +4478,7 @@ impl Renderer {
|
||||
"pipelines ready (multi-draw {}, occlusion culling {}, GPU culling {}, GPU timer {})",
|
||||
on_off(multi_draw),
|
||||
on_off(cull.is_some()),
|
||||
if gpu_culling_supported {
|
||||
if gpu_culling_offer == crate::graphics::GpuCullingOffer::Offered {
|
||||
"available"
|
||||
} else {
|
||||
"unavailable"
|
||||
@@ -4501,7 +4524,7 @@ impl Renderer {
|
||||
cull_on: cull.is_some(),
|
||||
sides_on: true,
|
||||
gpu_culling: None,
|
||||
gpu_culling_supported,
|
||||
gpu_culling_offer,
|
||||
gpu_frame: false,
|
||||
#[cfg(debug_assertions)]
|
||||
gpu_checks: 0,
|
||||
@@ -4553,6 +4576,7 @@ impl Renderer {
|
||||
see_through_pipeline,
|
||||
world_primitives,
|
||||
text_polygon_offset_pipeline,
|
||||
world_text_pipeline,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
end_sky: end_sky::EndSky::default(),
|
||||
@@ -4682,6 +4706,7 @@ impl Renderer {
|
||||
pack_material_atlases: None,
|
||||
pack_terrain_flat: false,
|
||||
separate_ao: false,
|
||||
pack_lava_layers: Vec::new(),
|
||||
hud,
|
||||
menu_blur: None,
|
||||
post_effects: None,
|
||||
@@ -4693,6 +4718,8 @@ impl Renderer {
|
||||
drawn_sections: 0,
|
||||
section_draws: 0,
|
||||
section_draw_calls: 0,
|
||||
frame_serial: 0,
|
||||
stats_frame: 0,
|
||||
uploaded_bytes: 0,
|
||||
started: Instant::now(),
|
||||
screenshot: None,
|
||||
@@ -5008,13 +5035,15 @@ impl Renderer {
|
||||
pub fn gpu_fading(&self) -> Option<u32> {
|
||||
self.gpu_culling.as_ref().map(|g| g.records.fading())
|
||||
}
|
||||
/// Switches GPU culling, for A/B checks. Its pass, its records of the
|
||||
/// resident sections and their flags while they fade are made when it is
|
||||
/// switched on (the next world frame decides the flags, `mark_fades`) and
|
||||
/// dropped when it is switched off, with the walk marks the flags gave:
|
||||
/// nothing is kept for it while it is off but the sections' other walk marks
|
||||
/// (`SectionGpu::listed`) and the cave-culling graph's bits
|
||||
/// (`occlusion::Reach`), which are the same either way.
|
||||
/// Switches GPU culling: the GPU Culling setting sets it (`apply_ui_settings`, after
|
||||
/// every settings change and every new renderer), and so does the control word. It
|
||||
/// turns on only where it is offered (`gpu_culling_offer`); unchanged, it does nothing.
|
||||
/// Its pass, its records of the resident sections and their flags while they
|
||||
/// fade are made when it is switched on (the next world frame decides the
|
||||
/// flags, `mark_fades`) and dropped when it is switched off, with the walk
|
||||
/// marks the flags gave: nothing is kept for it while it is off but the
|
||||
/// sections' other walk marks (`SectionGpu::listed`) and the cave-culling
|
||||
/// graph's bits (`occlusion::Reach`), which are the same either way.
|
||||
pub fn set_gpu_culling(&mut self, on: bool) -> Result<()> {
|
||||
if !on {
|
||||
if self.gpu_culling.take().is_some() {
|
||||
@@ -5026,12 +5055,12 @@ impl Renderer {
|
||||
if self.gpu_culling.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if !self.gpu_culling_supported {
|
||||
return Err("GPU culling is unavailable on this device".into());
|
||||
if let Some(why) = self.gpu_culling_offer.reason() {
|
||||
return Err(why.into());
|
||||
}
|
||||
let Some(mut gpu) = gpu_culling::GpuCulling::new(&self.device) else {
|
||||
// Not offered from now on.
|
||||
self.gpu_culling_supported = false;
|
||||
// Not offered from now on: its switch greys out.
|
||||
self.offer_gpu_culling(crate::graphics::GpuCullingOffer::Unsupported);
|
||||
return Err("GPU culling could not be set up on this device".into());
|
||||
};
|
||||
let sections = self.layout.sections();
|
||||
@@ -5047,6 +5076,15 @@ impl Renderer {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// GPU culling offered or not from now on, here and in the settings' switch.
|
||||
fn offer_gpu_culling(&mut self, offer: crate::graphics::GpuCullingOffer) {
|
||||
self.gpu_culling_offer = offer;
|
||||
crate::graphics::offer_gpu_culling(offer);
|
||||
}
|
||||
/// Whether GPU culling is offered here, and why not (the status's `gpu_culling_offer`).
|
||||
pub fn gpu_culling_offer(&self) -> crate::graphics::GpuCullingOffer {
|
||||
self.gpu_culling_offer
|
||||
}
|
||||
/// Debug builds: GPU culling's records are the sections' as they are now,
|
||||
/// compaction's moves included: each section it keeps a record of
|
||||
/// (`SectionGpu::gpu_recorded`) has its own, no record is left without its
|
||||
@@ -6395,6 +6433,10 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Lava's sprite layers (still and flowing), whose faces a pack's solid terrain draws.
|
||||
pub fn set_pack_lava_layers(&mut self, layers: Vec<u16>) {
|
||||
self.pack_lava_layers = layers;
|
||||
}
|
||||
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
|
||||
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
|
||||
/// stages' labels; on an error the pack that ran keeps running.
|
||||
@@ -6458,6 +6500,9 @@ impl Renderer {
|
||||
}
|
||||
});
|
||||
horizon_shaders::GameInputs {
|
||||
terrain_parts: terrain
|
||||
.as_ref()
|
||||
.map(|t| pack_atlas::terrain_parts(t, &self.pack_lava_layers)),
|
||||
terrain,
|
||||
terrain_geometry,
|
||||
entity_geometry: Some(pack_entities::geometry_inputs()),
|
||||
@@ -7434,15 +7479,21 @@ impl Renderer {
|
||||
mark_fades(sections, &mut gpu.records, &mut gpu.fades, rows, fades);
|
||||
}
|
||||
// The near solid sections culled and drawn on the GPU (`gpu_culling`): never where a
|
||||
// shader pack's terrain, a shadow pass or the water mirror draws the CPU's lists.
|
||||
// Decided before the walk, so that a pass whose buffers no longer fit can still
|
||||
// switch GPU culling off.
|
||||
// shader pack's terrain, a shadow pass or the water mirror draws the CPU's lists, nor
|
||||
// under a native pack's `[displacement]`, whose grid takes its quads from the walk's
|
||||
// `visible`. Decided before the walk, so that a pass whose buffers no longer fit can
|
||||
// still switch GPU culling off.
|
||||
let radius = (frame.detail_distance / 16.0) as i32;
|
||||
let displaced = self
|
||||
.native
|
||||
.as_ref()
|
||||
.is_some_and(|n| n.displacement.is_some());
|
||||
let mut gpu_frame = self.gpu_culling.is_some()
|
||||
&& !frame.title
|
||||
&& !pack_terrain
|
||||
&& shadow_view.is_none()
|
||||
&& mirror_plane.is_none();
|
||||
&& mirror_plane.is_none()
|
||||
&& !displaced;
|
||||
#[cfg(debug_assertions)]
|
||||
if gpu_frame {
|
||||
if self.gpu_checks.is_multiple_of(64) {
|
||||
@@ -7450,6 +7501,14 @@ impl Renderer {
|
||||
}
|
||||
self.gpu_checks = self.gpu_checks.wrapping_add(1);
|
||||
}
|
||||
self.frame_serial += 1;
|
||||
let serial = self.frame_serial;
|
||||
// GPU frames' statistics come back a frame or a few later (`GpuCulling::collect`).
|
||||
if let Some(gpu) = &mut self.gpu_culling
|
||||
&& let Some((frame, stats)) = gpu.collect(&self.device)
|
||||
{
|
||||
self.show_stats(frame, stats);
|
||||
}
|
||||
// The walk reads the graph GPU culling is given (checked after it).
|
||||
#[cfg(debug_assertions)]
|
||||
let graph_given = self.occlusion.generation();
|
||||
@@ -7476,7 +7535,10 @@ impl Renderer {
|
||||
// above or below the level), or whose eye is not a number, is the CPU's.
|
||||
gpu_frame = prepared == gpu_culling::Prepared::Gpu;
|
||||
if prepared == gpu_culling::Prepared::OutOfMemory {
|
||||
log::warn!("Horizon renderer: GPU culling off: its buffers do not fit");
|
||||
log::warn!(
|
||||
"Horizon renderer: GPU culling off until the next start: its buffers do not fit in video memory"
|
||||
);
|
||||
self.offer_gpu_culling(crate::graphics::GpuCullingOffer::OutOfMemory);
|
||||
let _ = self.set_gpu_culling(false);
|
||||
}
|
||||
}
|
||||
@@ -7605,8 +7667,8 @@ impl Renderer {
|
||||
// the sections GPU culling draws: every layer animates.
|
||||
self.animate(&mut encoder, frame.ticks, !frame.title && !gpu_frame);
|
||||
}
|
||||
self.drawn_sections = visible.len() + visible_lods.len();
|
||||
let instances = self.drawn_sections + shadow_visible.len();
|
||||
let listed = visible.len() + visible_lods.len();
|
||||
let instances = listed + shadow_visible.len();
|
||||
if instances as u64 > self.instance_capacity {
|
||||
self.instance_capacity = (instances as u64).next_power_of_two();
|
||||
self.instances = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
@@ -7737,6 +7799,9 @@ impl Renderer {
|
||||
// (`gpu_fading`), those without a record and those past the distance (far terrain's
|
||||
// cutout, and columns leaving).
|
||||
let from = resident::camera_column(camera);
|
||||
// Listed sections whose solid quads the pass draws: it counts them among the
|
||||
// sections it kept.
|
||||
let mut gpu_solid = 0;
|
||||
for (i, v) in visible.iter().enumerate() {
|
||||
let s = self.sections.get(v.slot);
|
||||
if gpu_frame
|
||||
@@ -7744,6 +7809,7 @@ impl Renderer {
|
||||
from, radius, v.key.0, v.key.2,
|
||||
))
|
||||
{
|
||||
gpu_solid += 1;
|
||||
continue;
|
||||
}
|
||||
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
|
||||
@@ -7994,7 +8060,7 @@ impl Renderer {
|
||||
}
|
||||
self.translucency_cursor += budget;
|
||||
let translucent_draws = self.draws.len() as u32 - solid_draws;
|
||||
self.section_draws = self.draws.len();
|
||||
let section_draws = self.draws.len();
|
||||
// The shadow camera's sections after the camera's: quads that can face the light (all of
|
||||
// them for `every_face`), then translucent ones (in their last sorted order; depth
|
||||
// decides there).
|
||||
@@ -8002,7 +8068,7 @@ impl Renderer {
|
||||
let mut shadow_translucent = Vec::new();
|
||||
self.shadow_runs.clear();
|
||||
if let Some(view) = &shadow_view {
|
||||
let base = self.drawn_sections;
|
||||
let base = listed;
|
||||
// How far below the nearest section to the light (squared, as bits, as `PageRuns`
|
||||
// takes distances): each band's draws go by page, a call per page and band.
|
||||
let height = |v: &resident::Visible| glam::Vec3::from(v.origin).dot(view.light);
|
||||
@@ -8075,8 +8141,9 @@ impl Renderer {
|
||||
let shadow_translucent = self.draws.len() as u32 - shadow_first - shadow_solid;
|
||||
laps.lap("r_nearby");
|
||||
// Metal replays a multi-draw as one draw call per entry.
|
||||
self.section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal {
|
||||
(0..self.section_draws)
|
||||
let mut section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal
|
||||
{
|
||||
(0..section_draws)
|
||||
.filter(|&i| {
|
||||
i == 0
|
||||
|| i == phase1_draws as usize
|
||||
@@ -8085,12 +8152,12 @@ impl Renderer {
|
||||
})
|
||||
.count()
|
||||
} else {
|
||||
self.section_draws
|
||||
section_draws
|
||||
};
|
||||
// GPU culling's draw calls, one per page. Its draws are not counted: the GPU
|
||||
// decides them, and nothing reads them back.
|
||||
// GPU culling's draw calls, one per page; its draws come back with its statistics
|
||||
// (`GpuCulling::copy_stats`).
|
||||
if gpu_frame && let Some(gpu) = &self.gpu_culling {
|
||||
self.section_draw_calls += gpu.draw_calls();
|
||||
section_draw_calls += gpu.draw_calls();
|
||||
}
|
||||
let debug_depth = frame.debug_lines.len() as u32;
|
||||
let debug_opaque = frame
|
||||
@@ -8242,6 +8309,8 @@ impl Renderer {
|
||||
translucent_particle_quads,
|
||||
translucent_item_quads,
|
||||
oit_entity_quads,
|
||||
world_text_quads,
|
||||
world_text_offset_quads,
|
||||
] = self.entity_batch.update(
|
||||
entities,
|
||||
frame.additive,
|
||||
@@ -8267,11 +8336,14 @@ impl Renderer {
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads
|
||||
+ oit_entity_quads;
|
||||
+ oit_entity_quads
|
||||
+ world_text_quads
|
||||
+ world_text_offset_quads;
|
||||
laps.lap("r_draw_lists");
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads = world_quads - oit_entity_quads;
|
||||
let base_quads =
|
||||
world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads;
|
||||
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
|
||||
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
|
||||
let (rebuilt, uniform) =
|
||||
@@ -8615,7 +8687,7 @@ impl Renderer {
|
||||
// where vanilla frames draw flat).
|
||||
&& !gpu_frame
|
||||
&& !self.draws.is_empty()
|
||||
&& (self.drawn_sections >= flat.min_sections
|
||||
&& (listed >= flat.min_sections
|
||||
|| pack_terrain
|
||||
|| shadow_view.is_some()) =>
|
||||
{
|
||||
@@ -8671,7 +8743,7 @@ impl Renderer {
|
||||
});
|
||||
}
|
||||
if flat_frame {
|
||||
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
|
||||
section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
|
||||
} else if self.multi_draw && !self.draws.is_empty() {
|
||||
if self.draws.len() as u64 > self.indirect_capacity {
|
||||
self.indirect_capacity = (self.draws.len() as u64).next_power_of_two();
|
||||
@@ -8689,12 +8761,6 @@ impl Renderer {
|
||||
bytemuck::cast_slice(&self.draws),
|
||||
);
|
||||
}
|
||||
// A GPU frame's walk leaves out the sections GPU culling draws, and nothing reads back
|
||||
// which it kept: every record counts but the flagged ones, which the walk lists, an
|
||||
// upper bound (the world icon waits for more than a few drawn).
|
||||
if gpu_frame && let Some(gpu) = &self.gpu_culling {
|
||||
self.drawn_sections += gpu.records.live() - gpu.records.fading() as usize;
|
||||
}
|
||||
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
format: Some(self.frame_format),
|
||||
..Default::default()
|
||||
@@ -8702,8 +8768,15 @@ impl Renderer {
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
timer.begin(&mut encoder);
|
||||
}
|
||||
if gpu_frame && let Some(gpu) = &self.gpu_culling {
|
||||
// The frame's statistics (`show_stats`): a CPU frame's at once; a GPU frame's when the
|
||||
// pass's counts come back, with the CPU's part of it: the sections the walk listed but
|
||||
// those whose solid quads the pass drew (it counts them), its draws and every call.
|
||||
let cpu = [listed - gpu_solid, section_draws, section_draw_calls];
|
||||
if gpu_frame && let Some(gpu) = &mut self.gpu_culling {
|
||||
gpu.dispatch(&mut encoder, self.own_timing.as_mut());
|
||||
gpu.copy_stats(&mut encoder, serial, cpu);
|
||||
} else {
|
||||
self.show_stats(serial, cpu);
|
||||
}
|
||||
// The world's targets: the screen, or the scaled target of `post`.
|
||||
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
|
||||
@@ -10007,6 +10080,29 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// Native world text is an ordinary depth-tested overlay, after
|
||||
// particles (also after the OIT composite) and before the hand.
|
||||
// Its own geometry is independent of block-entity lifetime.
|
||||
if world_text_quads + world_text_offset_quads > 0 {
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
let first = base_quads + oit_entity_quads;
|
||||
for (pipeline, count, offset) in [
|
||||
(&self.world_text_pipeline, world_text_quads, first),
|
||||
(
|
||||
&self.text_polygon_offset_pipeline,
|
||||
world_text_offset_quads,
|
||||
first + world_text_quads,
|
||||
),
|
||||
] {
|
||||
if count > 0 {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.draw_indexed(0..count * 6, (offset * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// LevelRenderer.executeClassicTransparency: translucent targets'
|
||||
// outlines follow terrain, so its blending cannot wash them out.
|
||||
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
|
||||
@@ -10385,16 +10481,16 @@ impl Renderer {
|
||||
&& (surface_frame.is_none() || self.config.present_mode != wgpu::PresentMode::Fifo)
|
||||
{
|
||||
let offscreen = surface_frame.is_none();
|
||||
// Culling and a shader pack's centre depth read back as soon as the frame is
|
||||
// submitted, as here.
|
||||
let cull = self.cull.as_mut().and_then(|c| c.submitted());
|
||||
// Occlusion culling's results, GPU culling's statistics and a shader pack's centre
|
||||
// depth are read back as soon as the frame is submitted, as here.
|
||||
let culling = self.after_submit();
|
||||
let center = match &mut self.pack_post {
|
||||
Some((_, Some(post))) => post.submitted(),
|
||||
_ => None,
|
||||
};
|
||||
let after = (cull.is_some() || center.is_some()).then(|| {
|
||||
let after = (culling.is_some() || center.is_some()).then(|| {
|
||||
Box::new(move || {
|
||||
if let Some(read) = cull {
|
||||
if let Some(read) = culling {
|
||||
read();
|
||||
}
|
||||
if let Some(read) = center {
|
||||
@@ -10429,9 +10525,7 @@ impl Renderer {
|
||||
self.first_frame = FirstFrame::Drawn;
|
||||
}
|
||||
let submission = crate::spans::time("submit", || self.queue.submit([commands]));
|
||||
if let Some(read) = after {
|
||||
read();
|
||||
} else if let Some(read) = self.cull.as_mut().and_then(|c| c.submitted()) {
|
||||
if let Some(read) = after.or_else(|| self.after_submit()) {
|
||||
read();
|
||||
}
|
||||
if let Some(timer) = &mut self.gpu_timer {
|
||||
@@ -10900,6 +10994,39 @@ pub(crate) fn copy_frame(
|
||||
buffer
|
||||
}
|
||||
impl Renderer {
|
||||
/// The frame's readbacks to start right after its submission (on the submit thread when
|
||||
/// the frame goes there): occlusion culling's results (`cull`) and GPU culling's
|
||||
/// statistics. GPU frames leave occlusion culling off, so a frame has one at most.
|
||||
fn after_submit(&mut self) -> Option<submit::AfterSubmit> {
|
||||
let cull = self.cull.as_mut().and_then(|c| c.submitted());
|
||||
let gpu = self.gpu_culling.as_mut().and_then(|g| g.submitted());
|
||||
match (cull, gpu) {
|
||||
(Some(c), Some(g)) => Some(Box::new(move || {
|
||||
c();
|
||||
g();
|
||||
})),
|
||||
(Some(c), None) => Some(Box::new(c)),
|
||||
(None, Some(g)) => Some(Box::new(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
/// Frame `frame`'s statistics shown, unless a newer frame's are: a CPU frame's as it is
|
||||
/// drawn, a GPU frame's when its counts come back (`GpuCulling::collect`).
|
||||
fn show_stats(&mut self, frame: u64, [sections, draws, calls]: [usize; 3]) {
|
||||
if frame > self.stats_frame {
|
||||
self.stats_frame = frame;
|
||||
(
|
||||
self.drawn_sections,
|
||||
self.section_draws,
|
||||
self.section_draw_calls,
|
||||
) = (sections, draws, calls);
|
||||
}
|
||||
}
|
||||
/// Frames between the last one drawn and the one whose statistics are shown: 0 after a
|
||||
/// CPU frame, one to three in GPU frames, while their counts are on their way back.
|
||||
pub fn stats_lag(&self) -> u64 {
|
||||
self.frame_serial - self.stats_frame
|
||||
}
|
||||
/// Take in the frame the submit thread submitted: start its readbacks and, offscreen,
|
||||
/// keep at most two frames ahead of the GPU, as a frame submitted in place does.
|
||||
fn land_frame(&mut self) {
|
||||
@@ -11414,6 +11541,26 @@ mod tests {
|
||||
assert!(!offered(&intel, counts, compute, 8, true, None));
|
||||
assert!(offered(&dx12, counts, compute, 8, true, None));
|
||||
assert!(!offered(&dx12, counts, compute, 8, true, Some(usize::MAX)));
|
||||
// The settings offer it there, but not on a software adapter (WARP, lavapipe),
|
||||
// whose pass would run on the CPU, and they say why not on DX12 with flat page groups.
|
||||
use crate::graphics::GpuCullingOffer as O;
|
||||
let offer = super::gpu_culling::offer;
|
||||
assert_eq!(offer(&vulkan, true, true), O::Offered);
|
||||
assert_eq!(
|
||||
offer(&adapter(wgpu::DeviceType::IntegratedGpu), true, false),
|
||||
O::Offered
|
||||
);
|
||||
assert_eq!(
|
||||
offer(&adapter(wgpu::DeviceType::Cpu), true, false),
|
||||
O::Software
|
||||
);
|
||||
assert_eq!(offer(&vulkan, false, false), O::Unsupported);
|
||||
assert_eq!(
|
||||
offer(&adapter(wgpu::DeviceType::Cpu), false, false),
|
||||
O::Unsupported
|
||||
);
|
||||
assert_eq!(offer(&dx12, true, false), O::Offered);
|
||||
assert_eq!(offer(&dx12, false, true), O::Dx12);
|
||||
}
|
||||
|
||||
/// The pack-flat rule: with Translucent Batching translucent quads have an arena of
|
||||
@@ -11718,7 +11865,7 @@ mod tests {
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
|
||||
@@ -14,7 +14,8 @@ use super::Vertex;
|
||||
/// material at the end: the render types vanilla gives an `OitPipelineSet`
|
||||
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
|
||||
/// ignored and they stay in the first one, in their order.
|
||||
const MATERIALS: usize = 10;
|
||||
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
|
||||
const MATERIALS: usize = 12;
|
||||
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
|
||||
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
@@ -51,6 +52,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
/// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays):
|
||||
/// drawn like an ordinary entity, pulled toward the camera.
|
||||
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
|
||||
/// A native world canvas, drawn after particles with normal text depth/culling.
|
||||
pub const WORLD_TEXT_OVERLAY: u32 = 39;
|
||||
/// The front of its eight-offset outlined text, using POLYGON_OFFSET.
|
||||
pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
|
||||
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
|
||||
@@ -59,6 +64,8 @@ fn material(layer: u32, oit: bool) -> usize {
|
||||
return 9;
|
||||
}
|
||||
match layer & 0xffff {
|
||||
WORLD_TEXT_OVERLAY => 10,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
@@ -118,7 +125,7 @@ impl EntityBatch {
|
||||
self.combined.clear();
|
||||
self.combined
|
||||
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
|
||||
for runs in &self.runs {
|
||||
for runs in &self.runs[..10] {
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
@@ -141,6 +148,27 @@ impl EntityBatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
// RenderType.text sorts alpha-blended quads from far to near. Keep
|
||||
// both world-canvas layers after existing materials (including eyes
|
||||
// moved out of additive); front text uses polygon offset.
|
||||
for runs in &self.runs[10..] {
|
||||
let start = self.combined.len();
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
self.combined[start..]
|
||||
.as_chunks_mut::<4>()
|
||||
.0
|
||||
.sort_by(|a, b| {
|
||||
let distance = |q: &[Vertex; 4]| {
|
||||
(0..3)
|
||||
.map(|i| ((q[0].pos[i] + q[2].pos[i]) * 0.5).powi(2))
|
||||
.sum::<f32>()
|
||||
};
|
||||
distance(b).total_cmp(&distance(a))
|
||||
});
|
||||
}
|
||||
if self.moved_additive == 0 {
|
||||
self.combined.extend_from_slice(additive);
|
||||
} else {
|
||||
@@ -160,6 +188,41 @@ impl EntityBatch {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
#[test]
|
||||
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
|
||||
let quad = |layer, x| {
|
||||
[Vertex {
|
||||
layer,
|
||||
pos: [x, 0.0, 0.0],
|
||||
..geometry(1, false)[0]
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [
|
||||
(0, 1.0),
|
||||
(35, 2.0),
|
||||
(OIT_ENTITY, 3.0),
|
||||
(WORLD_TEXT_OVERLAY, 4.0),
|
||||
(WORLD_TEXT_OVERLAY, 5.0),
|
||||
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let additive: Vec<_> = [(OIT_ENTITY | 2, 7.0), (2, 8.0)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
assert_eq!(
|
||||
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
|
||||
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
|
||||
);
|
||||
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(
|
||||
positions,
|
||||
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
|
||||
);
|
||||
}
|
||||
|
||||
// Retain the original implementation as the equivalence/benchmark oracle.
|
||||
fn baseline(
|
||||
@@ -257,12 +320,12 @@ mod tests {
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: the tag changes nothing, the quads keep their place.
|
||||
let counts = batch.update(&entities, &[], &[], &[], false);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
|
||||
// On: the tagged quads come last, in order.
|
||||
let counts = batch.update(&entities, &[], &[], &[], true);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
//! walk's would and a thin shell more, alike on every backend; a frame whose
|
||||
//! sums could leave i32 is drawn by the CPU (`Prepared::Cpu`).
|
||||
//!
|
||||
//! Its statistics, the sections the pass kept and their draws (`counts[0]`,
|
||||
//! `counts[1]`), are copied after the pass into one of `READBACKS` small
|
||||
//! buffers with the CPU's part of the same frame (`copy_stats`), mapped once the
|
||||
//! frame is submitted (`submitted`, from the frame's after-submit hook) and
|
||||
//! taken in a frame or a few later (`collect`); nothing waits for them.
|
||||
//!
|
||||
//! A record also holds where its section's buckets of solid quads by the side
|
||||
//! they face end, and what `mesher::Sides::visible_from` compares the eye with
|
||||
//! for each, its margin in (`Sides::thresholds`); the tables hold the eye less
|
||||
@@ -70,9 +76,17 @@ use super::{
|
||||
submit::Queue,
|
||||
upload::Uploads,
|
||||
};
|
||||
use crate::mesher::Sides;
|
||||
use crate::{graphics::GpuCullingOffer, mesher::Sides};
|
||||
use glam::{DVec3, DVec4, Vec3};
|
||||
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::BinaryHeap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// One section (`gpu_culling.wgsl`'s `Section`).
|
||||
#[repr(C)]
|
||||
@@ -265,6 +279,15 @@ struct Params {
|
||||
/// Counts before the pages' (the shader's `STATS`): the sections `cull` kept,
|
||||
/// and the draws, fewer only where a region was short.
|
||||
const STATS: u64 = 2;
|
||||
/// Statistics on their way back at once, a frame or a few late so the CPU never
|
||||
/// waits (as `cull`'s results).
|
||||
const READBACKS: usize = 3;
|
||||
const FREE: u8 = 0;
|
||||
const PENDING: u8 = 1;
|
||||
const MAPPED: u8 = 2;
|
||||
/// Its mapping failed or the device lost its buffer: never copied into or
|
||||
/// unmapped again, so the statistics stop (as `timing::GpuTimer`'s).
|
||||
const FAILED: u8 = 3;
|
||||
/// In `Params::flags` (the shader's `REACH`): cave culling's graph is in
|
||||
/// `reach`.
|
||||
const REACH: u32 = 1;
|
||||
@@ -475,10 +498,31 @@ pub(super) fn supported(
|
||||
// The pass binds 8, the last cave culling's graph.
|
||||
&& limits.max_storage_buffers_per_shader_stage >= 8
|
||||
&& !super::flat::is_broken_intel_vulkan(info)
|
||||
// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
|
||||
// pulled solid pipelines (those of a device with flat page groups) read the page.
|
||||
// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
|
||||
&& !(info.backend == wgpu::Backend::Dx12 && flat_min_sections.is_some())
|
||||
&& !loses_base_vertex(info, flat_min_sections.is_some())
|
||||
}
|
||||
|
||||
/// DX12's count draws leave each draw's base vertex out of `vertex_index`, by which the
|
||||
/// pulled solid pipelines (those of a device with flat page groups) read the page.
|
||||
/// shortcut: not offered there, until GPU culling's draws carry their base vertex otherwise.
|
||||
pub(super) fn loses_base_vertex(info: &wgpu::AdapterInfo, flat_page_groups: bool) -> bool {
|
||||
info.backend == wgpu::Backend::Dx12 && flat_page_groups
|
||||
}
|
||||
|
||||
/// What the settings offer (`graphics::GpuCullingOffer`): where `supported`
|
||||
/// holds, but not on a software adapter (WARP, lavapipe), whose pass would run
|
||||
/// on the CPU beside the frame, as occlusion culling's default has it; where it
|
||||
/// doesn't, DX12 with flat page groups says why (`loses_base_vertex`).
|
||||
pub(super) fn offer(
|
||||
info: &wgpu::AdapterInfo,
|
||||
supported: bool,
|
||||
flat_page_groups: bool,
|
||||
) -> GpuCullingOffer {
|
||||
match (supported, super::is_software(info.device_type)) {
|
||||
(false, _) if loses_base_vertex(info, flat_page_groups) => GpuCullingOffer::Dx12,
|
||||
(false, _) => GpuCullingOffer::Unsupported,
|
||||
(true, true) => GpuCullingOffer::Software,
|
||||
(true, false) => GpuCullingOffer::Offered,
|
||||
}
|
||||
}
|
||||
|
||||
/// A buffer that is recreated larger when it must hold more.
|
||||
@@ -653,6 +697,7 @@ impl Records {
|
||||
self.fading = 0;
|
||||
}
|
||||
/// The sections with a record.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn live(&self) -> usize {
|
||||
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
|
||||
self.records.len() - free as usize
|
||||
@@ -730,6 +775,18 @@ impl Fades {
|
||||
}
|
||||
}
|
||||
|
||||
/// One GPU frame's statistics on their way back: `counts[..STATS]`, and what
|
||||
/// the CPU drew of the same frame.
|
||||
struct Readback {
|
||||
buffer: wgpu::Buffer,
|
||||
state: Arc<AtomicU8>,
|
||||
/// Its frame (gfx.rs `frame_serial`), and the CPU's part of its statistics:
|
||||
/// the sections it drew that the pass didn't count, its section draws, and
|
||||
/// every draw call.
|
||||
frame: u64,
|
||||
cpu: [usize; 3],
|
||||
}
|
||||
|
||||
/// The pass, its buffers and the records it draws from: made when GPU culling
|
||||
/// is switched on, dropped when it is switched off.
|
||||
pub(super) struct GpuCulling {
|
||||
@@ -769,6 +826,11 @@ pub(super) struct GpuCulling {
|
||||
/// This frame's updates and table words, kept for their room.
|
||||
scratch: Vec<Update>,
|
||||
words: Vec<u32>,
|
||||
/// The statistics on their way back (`Readback`), the next one to copy
|
||||
/// into, and this frame's copy.
|
||||
readbacks: Vec<Readback>,
|
||||
next: usize,
|
||||
current: Option<usize>,
|
||||
}
|
||||
|
||||
impl GpuCulling {
|
||||
@@ -828,6 +890,21 @@ impl GpuCulling {
|
||||
return None;
|
||||
}
|
||||
use wgpu::BufferUsages as U;
|
||||
let readbacks = (0..READBACKS)
|
||||
.map(|_| {
|
||||
Some(Readback {
|
||||
buffer: try_create(
|
||||
device,
|
||||
"GPU culling statistics",
|
||||
STATS * 4,
|
||||
U::MAP_READ | U::COPY_DST,
|
||||
)?,
|
||||
state: Arc::new(AtomicU8::new(FREE)),
|
||||
frame: 0,
|
||||
cpu: [0; 3],
|
||||
})
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(Self {
|
||||
pipelines,
|
||||
layout,
|
||||
@@ -859,7 +936,8 @@ impl GpuCulling {
|
||||
device,
|
||||
"GPU culling counts",
|
||||
(STATS + 64) * 4,
|
||||
U::STORAGE | U::INDIRECT | U::COPY_DST | TEST_READ,
|
||||
// The counts are copied out for the statistics (`copy_stats`).
|
||||
U::STORAGE | U::INDIRECT | U::COPY_DST | U::COPY_SRC,
|
||||
)?,
|
||||
draws: Grown::new(
|
||||
device,
|
||||
@@ -891,6 +969,9 @@ impl GpuCulling {
|
||||
pass_pages: 1,
|
||||
scratch: Vec::new(),
|
||||
words: Vec::new(),
|
||||
readbacks,
|
||||
next: 0,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1131,6 +1212,78 @@ impl GpuCulling {
|
||||
pub fn draw_calls(&self) -> usize {
|
||||
self.pages.len()
|
||||
}
|
||||
|
||||
/// After `dispatch`, in the same encoder: the pass's counts copied out with
|
||||
/// the CPU's part of frame `frame` (`Readback::cpu`), into the next readback
|
||||
/// if it is free. While three frames' are on their way, this one's
|
||||
/// statistics are never shown.
|
||||
pub fn copy_stats(&mut self, encoder: &mut wgpu::CommandEncoder, frame: u64, cpu: [usize; 3]) {
|
||||
let r = &mut self.readbacks[self.next];
|
||||
if r.state.load(Ordering::Acquire) != FREE {
|
||||
return;
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&self.counts.buffer, 0, &r.buffer, 0, STATS * 4);
|
||||
(r.frame, r.cpu) = (frame, cpu);
|
||||
self.current = Some(self.next);
|
||||
}
|
||||
|
||||
/// The frame's statistics to read back, started by calling it right after
|
||||
/// the frame's submission (gfx's after-submit hook, on the submit thread
|
||||
/// when the frame goes there), as `cull::Cull::submitted`.
|
||||
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
|
||||
let i = self.current.take()?;
|
||||
let r = &self.readbacks[i];
|
||||
let (buffer, state) = (r.buffer.clone(), r.state.clone());
|
||||
state.store(PENDING, Ordering::Release);
|
||||
self.next = (i + 1) % READBACKS;
|
||||
Some(move || {
|
||||
buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FAILED },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// The statistics that came back, their readbacks freed: the newest
|
||||
/// frame's, as (frame, [sections drawn, section draws, draw calls]), the
|
||||
/// pass's counts added to the CPU's part. Called as a frame starts, so a
|
||||
/// copy no submission took (a frame that failed on the way) is dropped.
|
||||
pub fn collect(&mut self, device: &wgpu::Device) -> Option<(u64, [usize; 3])> {
|
||||
self.current = None;
|
||||
if device.poll(wgpu::PollType::Poll).is_err() {
|
||||
return None;
|
||||
}
|
||||
let mut newest: Option<(u64, [usize; 3])> = None;
|
||||
for r in &self.readbacks {
|
||||
if r.state.load(Ordering::Acquire) != MAPPED {
|
||||
continue;
|
||||
}
|
||||
{
|
||||
let Ok(data) = r.buffer.slice(..).get_mapped_range() else {
|
||||
// Device loss can destroy a buffer after its mapping
|
||||
// succeeded; unmapping it would panic (`timing::GpuTimer`).
|
||||
r.state.store(FAILED, Ordering::Release);
|
||||
continue;
|
||||
};
|
||||
// The mapping need not be 4-byte aligned: read the words unaligned.
|
||||
let [kept, draws]: [u32; 2] = bytemuck::pod_read_unaligned(&data[..8]);
|
||||
let [sections, cpu_draws, calls] = r.cpu;
|
||||
if newest.is_none_or(|(frame, _)| r.frame > frame) {
|
||||
newest = Some((
|
||||
r.frame,
|
||||
[sections + kept as usize, cpu_draws + draws as usize, calls],
|
||||
));
|
||||
}
|
||||
}
|
||||
r.buffer.unmap();
|
||||
r.state.store(FREE, Ordering::Release);
|
||||
}
|
||||
newest
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2581,8 +2734,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
gpu.dispatch(&mut encoder, None);
|
||||
// Its statistics come back with a CPU part of the case's own.
|
||||
let cpu = [case * 7, case * 3, case];
|
||||
gpu.copy_stats(&mut encoder, case as u64, cpu);
|
||||
uploads.finish(&encoder);
|
||||
queue.submit([encoder.finish()]);
|
||||
gpu.submitted().expect("a readback free")();
|
||||
let pages = u64::from(gpu.pass_pages);
|
||||
let counts = read(device, queue, &gpu.counts.buffer, (STATS + pages) * 4);
|
||||
let draws = read(device, queue, &gpu.draws.buffer, gpu.draws.bytes);
|
||||
@@ -2619,6 +2776,13 @@ mod tests {
|
||||
[kept_sections, draw_count],
|
||||
"case {case}: sections kept, draws"
|
||||
);
|
||||
// The reads above waited for the GPU.
|
||||
let (kept, drawn) = (kept_sections as usize, draw_count as usize);
|
||||
assert_eq!(
|
||||
gpu.collect(device),
|
||||
Some((case as u64, [cpu[0] + kept, cpu[1] + drawn, cpu[2]])),
|
||||
"case {case}: statistics"
|
||||
);
|
||||
compared += kept_sections as usize;
|
||||
}
|
||||
// Not a comparison of empty lists, nor of a test that keeps everything.
|
||||
@@ -2645,6 +2809,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The statistics come back with their frame, in place and from the submit
|
||||
/// thread, submitted as gfx.rs submits frames (`submit_later`, or in place
|
||||
/// with its `after` handed back): three frames on their way fill the ring, a
|
||||
/// fourth's are skipped, `collect` takes in the newest; and a readback whose
|
||||
/// buffer the device lost after its mapping goes `FAILED`, never copied into
|
||||
/// or unmapped again (as `timing::GpuTimer`'s), so the statistics stop.
|
||||
#[test]
|
||||
fn the_statistics_come_back_with_their_frame() {
|
||||
use super::super::submit::{AfterSubmit, Job};
|
||||
on_each_device(|device, queue| {
|
||||
for threaded in [false, true] {
|
||||
let queue = Queue::new((**queue).clone(), threaded);
|
||||
let mut gpu = GpuCulling::new(device).expect("the pass");
|
||||
// As if the pass kept 5 sections and drew 7 times.
|
||||
queue.write_buffer(&gpu.counts.buffer, 0, bytemuck::cast_slice(&[5_u32, 7]));
|
||||
// Whether the frame's statistics went out.
|
||||
let frame = |gpu: &mut GpuCulling, f: u64| {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
gpu.copy_stats(&mut encoder, f, [f as usize, 10, 3]);
|
||||
let after = gpu.submitted().map(|g| Box::new(g) as AfterSubmit);
|
||||
let sent = after.is_some();
|
||||
let job = Job {
|
||||
encoder,
|
||||
surface: None,
|
||||
after,
|
||||
};
|
||||
if let Err(back) = queue.submit_later(job) {
|
||||
queue.submit([back.encoder.finish()]);
|
||||
if let Some(read) = back.after {
|
||||
read();
|
||||
}
|
||||
}
|
||||
sent
|
||||
};
|
||||
let landed = |gpu: &mut GpuCulling| {
|
||||
queue.landed();
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
gpu.collect(device)
|
||||
};
|
||||
for f in 1..=3 {
|
||||
assert!(frame(&mut gpu, f), "frame {f}, threaded {threaded}");
|
||||
}
|
||||
assert!(!frame(&mut gpu, 4), "a full ring, threaded {threaded}");
|
||||
assert_eq!(landed(&mut gpu), Some((3, [3 + 5, 10 + 7, 3])));
|
||||
assert_eq!(gpu.collect(device), None);
|
||||
assert!(frame(&mut gpu, 5));
|
||||
assert_eq!(landed(&mut gpu), Some((5, [5 + 5, 10 + 7, 3])));
|
||||
// Frame 6's buffer destroyed after its mapping (#6320).
|
||||
let lost = gpu.next;
|
||||
assert!(frame(&mut gpu, 6));
|
||||
queue.landed();
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
gpu.readbacks[lost].buffer.destroy();
|
||||
assert_eq!(gpu.collect(device), None);
|
||||
for f in 7..=12 {
|
||||
let sent = frame(&mut gpu, f);
|
||||
let back = landed(&mut gpu);
|
||||
let state = gpu.readbacks[lost].state.load(Ordering::Acquire);
|
||||
assert_eq!(state, FAILED, "frame {f}, threaded {threaded}");
|
||||
// The two other readbacks, then none.
|
||||
assert_eq!(sent, f <= 8, "frame {f}, threaded {threaded}");
|
||||
assert_eq!(back.is_some(), f <= 8, "frame {f}, threaded {threaded}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `draw`'s count draws draw what the pass kept, page by page: each kept
|
||||
/// section's quads from its own range, all of them without an eye and those
|
||||
/// of its runs that can face one with it, with its own origin, which the
|
||||
|
||||
@@ -510,15 +510,24 @@ impl NativeFrame {
|
||||
|
||||
/// Writes this frame's inputs, for a world of `size`.
|
||||
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
|
||||
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
|
||||
// Under `HORIZON_STILL` the world's ticks are the clock (`PackInputs::still_ticks`), and
|
||||
// eased values sit at their targets.
|
||||
let still = frame.pack.still_ticks;
|
||||
let seconds = still.map_or_else(
|
||||
|| self.start.elapsed().as_secs_f32() % 3600.0,
|
||||
|ticks| (ticks as f32 / 20.0) % 3600.0,
|
||||
);
|
||||
let now = std::time::Instant::now();
|
||||
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
|
||||
let since = match still {
|
||||
Some(_) => f32::INFINITY,
|
||||
None => self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()),
|
||||
};
|
||||
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
|
||||
let mut inputs = frame_inputs(
|
||||
frame,
|
||||
size,
|
||||
seconds,
|
||||
self.frames,
|
||||
still.map_or(self.frames, |ticks| ticks as u32),
|
||||
self.shadow_camera.as_ref(),
|
||||
);
|
||||
inputs.wetness = self.wetness.0;
|
||||
@@ -551,7 +560,13 @@ impl NativeFrame {
|
||||
(f32::from(u8::from(inputs.eye_in == 1)), 0.25),
|
||||
(f32::from(u8::from(inputs.eye_in == 2)), 0.25),
|
||||
];
|
||||
let dt = inputs.delta_time;
|
||||
let dt = match still {
|
||||
Some(_) => {
|
||||
inputs.delta_time = 0.05;
|
||||
f32::INFINITY
|
||||
}
|
||||
None => inputs.delta_time,
|
||||
};
|
||||
for (value, (target, half_life)) in self.eye.iter_mut().zip(targets) {
|
||||
*value = eased(*value, target, half_life, dt);
|
||||
}
|
||||
|
||||
@@ -519,6 +519,36 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
}\n \
|
||||
}\n";
|
||||
|
||||
/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain:
|
||||
/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque
|
||||
/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID`
|
||||
/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's
|
||||
/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with
|
||||
/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`.
|
||||
///
|
||||
/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a
|
||||
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
|
||||
/// holds each vertex's own ambient occlusion.
|
||||
pub fn terrain_parts(
|
||||
terrain: &horizon_shaders::GbuffersInputs,
|
||||
lava: &[u16],
|
||||
) -> [horizon_shaders::GbuffersInputs; 2] {
|
||||
let opaque = crate::mesher::OPAQUE_LAYER;
|
||||
let lava: String = lava
|
||||
.iter()
|
||||
.map(|l| format!(" || hz_v_layer == {l}u"))
|
||||
.collect();
|
||||
let part = |solid: bool| {
|
||||
let mut inputs = terrain.clone();
|
||||
inputs.after.push_str(&format!(
|
||||
" bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \
|
||||
if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
|
||||
));
|
||||
inputs
|
||||
};
|
||||
[part(true), part(false)]
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
|
||||
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
|
||||
|
||||
@@ -43,6 +43,9 @@ mod shadow_faces_tests;
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod weather_depth_tests;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
@@ -516,8 +519,14 @@ pub struct PackPost {
|
||||
passes: Vec<Pass>,
|
||||
/// Shadow cache for temporal reprojection.
|
||||
shadow_cache: ShadowCache,
|
||||
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
|
||||
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`;
|
||||
/// when the pack draws terrain in two parts, solid terrain only (`gbuffers_terrain_solid` or
|
||||
/// its fallback).
|
||||
terrain: Option<Pass>,
|
||||
/// What draws cutout terrain (`gbuffers_terrain_cutout` or its fallback), after `terrain`,
|
||||
/// when the pack draws terrain in two parts. A pack with no program for one part has only
|
||||
/// the other.
|
||||
terrain_cutout: Option<Pass>,
|
||||
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
|
||||
water: Option<Pass>,
|
||||
/// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices.
|
||||
@@ -2163,10 +2172,15 @@ impl PackPost {
|
||||
ids: block_ids.as_ref().map(|i| &i.0),
|
||||
cull: g.cull,
|
||||
});
|
||||
let terrain = post
|
||||
let split = post.terrain.is_some() && post.terrain_cutout.is_some();
|
||||
let mut terrain = post
|
||||
.terrain
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let mut terrain_cutout = post
|
||||
.terrain_cutout
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let water = post
|
||||
.water
|
||||
.zip(terrain_geometry.as_ref())
|
||||
@@ -2295,7 +2309,7 @@ impl PackPost {
|
||||
let weather_geometry = world.weather.then(|| Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
depth_write: post.rain_depth,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
@@ -2371,8 +2385,31 @@ impl PackPost {
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
|
||||
if let Some(t) = &terrain {
|
||||
notes.push(format!("terrain draws with {}", t.name));
|
||||
// Terrain in two parts draws both or neither: one alone would leave the other part's
|
||||
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
|
||||
// program of a pack that doesn't split can't draw.
|
||||
if split
|
||||
&& terrain.is_some() != terrain_cutout.is_some()
|
||||
&& let Some(p) = terrain.take().or(terrain_cutout.take())
|
||||
{
|
||||
notes.push(format!(
|
||||
"{}: the other part of terrain can't draw; the game draws all terrain",
|
||||
p.name
|
||||
));
|
||||
}
|
||||
match (&terrain, &mut terrain_cutout) {
|
||||
(Some(t), None) => notes.push(format!("terrain draws with {}", t.name)),
|
||||
(t, Some(c)) => {
|
||||
if let Some(t) = t {
|
||||
notes.push(format!("solid terrain draws with {}", t.name));
|
||||
}
|
||||
notes.push(format!("cutout terrain draws with {}", c.name));
|
||||
// Timed (and labelled) apart from the solid part when it runs the same program.
|
||||
if t.as_ref().is_some_and(|t| t.name == c.name) {
|
||||
c.name.push_str(" (cutout)");
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
for (part, pass) in [
|
||||
("terrain", &shadow_terrain),
|
||||
@@ -2456,6 +2493,7 @@ impl PackPost {
|
||||
.iter()
|
||||
.chain(&shadow_comp)
|
||||
.chain(&terrain)
|
||||
.chain(&terrain_cutout)
|
||||
.chain(&water))
|
||||
.chain(shadow_terrain.iter().chain(&shadow_water))
|
||||
.chain(sky.iter().flat_map(|s| s.basic.iter().chain(&s.textured)))
|
||||
@@ -2513,6 +2551,7 @@ impl PackPost {
|
||||
.iter()
|
||||
.flat_map(|s| s.basic.iter().chain(&s.textured))
|
||||
.chain(&terrain)
|
||||
.chain(&terrain_cutout)
|
||||
.chain(entities.iter().flat_map(|e| e.passes.iter().flatten()))
|
||||
.chain(&clouds)
|
||||
.chain(&lines)
|
||||
@@ -2528,6 +2567,7 @@ impl PackPost {
|
||||
}
|
||||
// Terrain tests against the world's depth (entities...), so the blit copies it then.
|
||||
let blit_depth = terrain.is_some()
|
||||
|| terrain_cutout.is_some()
|
||||
|| water.is_some()
|
||||
|| sky.is_some()
|
||||
|| entities.as_ref().is_some_and(Entities::drawn)
|
||||
@@ -2665,6 +2705,7 @@ impl PackPost {
|
||||
setup_pending: true,
|
||||
passes,
|
||||
terrain,
|
||||
terrain_cutout,
|
||||
water,
|
||||
sky,
|
||||
entities,
|
||||
@@ -3153,7 +3194,8 @@ impl PackPost {
|
||||
}
|
||||
|
||||
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
|
||||
/// the game's draws.
|
||||
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
|
||||
/// same draws.
|
||||
fn draw_terrain(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -3163,13 +3205,35 @@ impl PackPost {
|
||||
list: TerrainList,
|
||||
draw: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
let slot = match list {
|
||||
self.draw_terrain_part(device, queue, encoder, size, list, false, draw);
|
||||
if list == TerrainList::Solid {
|
||||
self.draw_terrain_part(device, queue, encoder, size, list, true, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pass that draws `list`, or the cutout part of the solid list.
|
||||
fn terrain_slot(&mut self, list: TerrainList, cutout: bool) -> &mut Option<Pass> {
|
||||
match list {
|
||||
TerrainList::Solid if cutout => &mut self.terrain_cutout,
|
||||
TerrainList::Solid => &mut self.terrain,
|
||||
TerrainList::Translucent => &mut self.water,
|
||||
TerrainList::ShadowSolid => &mut self.shadow_terrain,
|
||||
TerrainList::ShadowTranslucent => &mut self.shadow_water,
|
||||
};
|
||||
let Some(mut pass) = slot.take() else {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_terrain_part(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
size: [u32; 2],
|
||||
list: TerrainList,
|
||||
cutout: bool,
|
||||
draw: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
let Some(mut pass) = self.terrain_slot(list, cutout).take() else {
|
||||
return;
|
||||
};
|
||||
let translucent = list.translucent();
|
||||
@@ -3207,12 +3271,7 @@ impl PackPost {
|
||||
if !pass.group.shadow() {
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
*match list {
|
||||
TerrainList::Solid => &mut self.terrain,
|
||||
TerrainList::Translucent => &mut self.water,
|
||||
TerrainList::ShadowSolid => &mut self.shadow_terrain,
|
||||
TerrainList::ShadowTranslucent => &mut self.shadow_water,
|
||||
} = Some(pass);
|
||||
*self.terrain_slot(list, cutout) = Some(pass);
|
||||
}
|
||||
|
||||
/// The sky's parts in vanilla's order, each with the program that draws it; one render pass
|
||||
@@ -3851,7 +3910,7 @@ impl PackPost {
|
||||
|
||||
/// Whether the pack draws terrain itself.
|
||||
pub fn has_terrain(&self) -> bool {
|
||||
self.terrain.is_some()
|
||||
self.terrain.is_some() || self.terrain_cutout.is_some()
|
||||
}
|
||||
|
||||
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
|
||||
@@ -3902,6 +3961,7 @@ impl PackPost {
|
||||
.passes
|
||||
.iter_mut()
|
||||
.chain(&mut self.terrain)
|
||||
.chain(&mut self.terrain_cutout)
|
||||
.chain(&mut self.water)
|
||||
.chain(sky)
|
||||
.chain(entities)
|
||||
@@ -4561,6 +4621,7 @@ impl PackPost {
|
||||
let mut shown = false;
|
||||
// A water-only pack still needs the opaque phase to populate requested snapshots.
|
||||
let mut terrain_drawn = self.terrain.is_none()
|
||||
&& self.terrain_cutout.is_none()
|
||||
&& self.sky.is_none()
|
||||
&& !self.entities.as_ref().is_some_and(Entities::drawn)
|
||||
&& self.clouds.is_none()
|
||||
@@ -4735,6 +4796,10 @@ mod snorm_tests;
|
||||
#[path = "pack_post/depth_test.rs"]
|
||||
mod depth_test;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_post/terrain_parts_tests.rs"]
|
||||
mod terrain_parts_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,15 +3,15 @@ use super::*;
|
||||
use crate::{gfx::Frame, hud::HudBatch};
|
||||
use wgpu::{TextureFormat as T, util::DeviceExt};
|
||||
|
||||
const SIZE: [u32; 2] = [16, 16];
|
||||
pub(super) const SIZE: [u32; 2] = [16, 16];
|
||||
|
||||
struct Gpu {
|
||||
pub(super) struct Gpu {
|
||||
adapter: wgpu::Adapter,
|
||||
device: wgpu::Device,
|
||||
pub(super) device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
}
|
||||
impl Gpu {
|
||||
fn new() -> Option<Self> {
|
||||
pub(super) fn new() -> Option<Self> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
@@ -27,7 +27,7 @@ impl Gpu {
|
||||
queue,
|
||||
})
|
||||
}
|
||||
fn texture(&self, format: T) -> wgpu::TextureView {
|
||||
pub(super) fn texture(&self, format: T) -> wgpu::TextureView {
|
||||
self.device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Original depth fixture input"),
|
||||
@@ -47,7 +47,7 @@ impl Gpu {
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
fn load(
|
||||
pub(super) fn load(
|
||||
&self,
|
||||
files: Vec<(String, String)>,
|
||||
world: World<'_>,
|
||||
@@ -122,7 +122,7 @@ impl Gpu {
|
||||
});
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
fn render(
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
post: &mut PackPost,
|
||||
color: &wgpu::TextureView,
|
||||
@@ -213,7 +213,7 @@ enum Scene {
|
||||
WaterOnly,
|
||||
}
|
||||
|
||||
const VERTEX: &str =
|
||||
pub(super) const VERTEX: &str =
|
||||
"#version 120\nvarying vec2 uv;void main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}";
|
||||
|
||||
fn shaders(sampler: &str, stage: Stage, scene: Scene) -> Vec<(String, String)> {
|
||||
@@ -561,7 +561,7 @@ fn attached_compute_preserves_distinct_hand_snapshots() {
|
||||
}
|
||||
|
||||
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
|
||||
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
eye: glam::DVec3::ZERO,
|
||||
eye_rotation: [0.0; 2],
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Solid and cutout terrain through `gbuffers_terrain_solid` and `gbuffers_terrain_cutout`
|
||||
//! (#9137). The expected programs are what Iris 1.11.7 drew with our probe packs (the PR has
|
||||
//! the numbers): with either program the pack draws solid terrain (quads over opaque texels, and
|
||||
//! lava) through `gbuffers_terrain_solid` and cutout terrain through `gbuffers_terrain_cutout`,
|
||||
//! the missing one falling back to `gbuffers_terrain`; solid terrain keeps a fragment written
|
||||
//! with alpha 0.05, cutout terrain loses it. Each quad draws whole with one of them, whatever
|
||||
//! its vertices' alphas (with `separateAo`, their own ambient occlusion). The programs here are
|
||||
//! the probes', cut down.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::{TextureFormat as T, util::DeviceExt};
|
||||
|
||||
const RED: [u8; 3] = [255, 0, 0];
|
||||
const GREEN: [u8; 3] = [0, 255, 0];
|
||||
const BLUE: [u8; 3] = [0, 0, 255];
|
||||
|
||||
/// A program writing `rgb` with `alpha` into colortex0.
|
||||
fn program(name: &str, rgb: [u8; 3], alpha: f32) -> [(String, String); 2] {
|
||||
let [r, g, b] = rgb.map(|c| f32::from(c) / 255.0);
|
||||
[
|
||||
(
|
||||
format!("{name}.vsh"),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
|
||||
),
|
||||
(
|
||||
format!("{name}.fsh"),
|
||||
format!(
|
||||
"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){{gl_FragData[0]=vec4({r:?},{g:?},{b:?},{alpha:?});}}"
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Lava's sprite layer in the fixture.
|
||||
const LAVA: u16 = 5;
|
||||
|
||||
/// The pack (`final` shows colortex0, cleared to black) drawn over three strips of terrain, left
|
||||
/// to right: a quad over opaque texels, a cutout quad, a lava quad, all with opaque vertex
|
||||
/// colours. Each strip's colour, `None` where nothing drew; `None` overall without a GPU.
|
||||
fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> {
|
||||
strips_with_alphas(programs, [255; 4])
|
||||
}
|
||||
|
||||
/// `strips` with the quads' corners' vertex alphas (bottom left, bottom right, top right, top
|
||||
/// left). Fails unless each strip is one colour.
|
||||
fn strips_with_alphas(
|
||||
programs: &[(&str, [u8; 3], f32)],
|
||||
alphas: [u8; 4],
|
||||
) -> Option<[Option<[u8; 3]>; 3]> {
|
||||
let files = programs
|
||||
.iter()
|
||||
.flat_map(|&(name, rgb, alpha)| program(name, rgb, alpha))
|
||||
.collect();
|
||||
let (strips, _) = strips_of(files, alphas)?;
|
||||
Some(strips)
|
||||
}
|
||||
|
||||
/// `strips_with_alphas` with the pack's terrain programs' `files`, and the built pack's notes
|
||||
/// when it draws no terrain itself (`None` when it does).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn strips_of(
|
||||
programs: Vec<(String, String)>,
|
||||
alphas: [u8; 4],
|
||||
) -> Option<([Option<[u8; 3]>; 3], Option<Vec<String>>)> {
|
||||
let Some(gpu) = Gpu::new() else {
|
||||
eprintln!("TERRAIN_PARTS_NO_ADAPTER");
|
||||
return None;
|
||||
};
|
||||
let mut files = vec![
|
||||
("final.vsh".to_owned(), VERTEX.to_owned()),
|
||||
(
|
||||
"final.fsh".to_owned(),
|
||||
"#version 120\nuniform sampler2D colortex0;varying vec2 uv;\
|
||||
const vec4 colortex0ClearColor = vec4(0.0, 0.0, 0.0, 1.0);\
|
||||
void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}"
|
||||
.to_owned(),
|
||||
),
|
||||
];
|
||||
files.extend(programs);
|
||||
// The game's terrain inputs as far as the parts read them: the sprite layer and the colour.
|
||||
let terrain = horizon_shaders::GbuffersInputs {
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n\
|
||||
layout(location=2)in uint hz_v_layer;\n\
|
||||
layout(location=3)in vec4 hz_v_color;\n"
|
||||
.into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);\
|
||||
hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
|
||||
&terrain,
|
||||
&[LAVA, LAVA + 1],
|
||||
)),
|
||||
terrain: Some(terrain),
|
||||
..Default::default()
|
||||
};
|
||||
const ATTRS: [wgpu::VertexAttribute; 3] =
|
||||
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 20,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
inputs,
|
||||
);
|
||||
let opaque = u32::from(crate::mesher::OPAQUE_LAYER);
|
||||
let mut vertices: Vec<u32> = Vec::new();
|
||||
for (i, layer) in [opaque | 1, 2, u32::from(LAVA)].into_iter().enumerate() {
|
||||
let (x0, x1) = (
|
||||
-1.0 + i as f32 * 2.0 / 3.0,
|
||||
-1.0 + (i + 1) as f32 * 2.0 / 3.0,
|
||||
);
|
||||
let corners = [[x0, -1.0], [x1, -1.0], [x1, 1.0], [x0, 1.0]];
|
||||
for corner in [0, 1, 2, 0, 2, 3] {
|
||||
let [x, y] = corners[corner];
|
||||
let color = u32::from(alphas[corner]) << 24 | 0x00FF_FFFF;
|
||||
vertices.extend([x.to_bits(), f32::to_bits(y), 0f32.to_bits(), layer, color]);
|
||||
}
|
||||
}
|
||||
let buffer = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Terrain parts fixture"),
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let undrawn = (!post.has_terrain()).then(|| post.notes.clone());
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let frame = fixture_frame(&hud);
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
|
||||
if list == TerrainList::Solid {
|
||||
rp.set_vertex_buffer(0, buffer.slice(..));
|
||||
rp.draw(0..18, 0..1);
|
||||
}
|
||||
});
|
||||
// Every pixel of each strip (columns 0-4, 6-9 and 11-15; 5 and 10 straddle two strips).
|
||||
let colour = |x: usize, y: usize| {
|
||||
let [r, g, b, _] = pixels[y * SIZE[0] as usize + x];
|
||||
Some([r, g, b]).filter(|&c| c != [0, 0, 0])
|
||||
};
|
||||
let strips = [0..5, 6..10, 11..16].map(|columns| {
|
||||
let first = colour(columns.start, 0);
|
||||
for x in columns.clone() {
|
||||
for y in 0..SIZE[1] as usize {
|
||||
assert_eq!(colour(x, y), first, "strip {columns:?} at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
first
|
||||
});
|
||||
Some((strips, undrawn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_and_cutout_programs_draw_their_own_terrain() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_cutout_program_cutout_terrain_falls_back_to_gbuffers_terrain() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(GREEN), Some(RED), Some(GREEN)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_solid_program_solid_terrain_falls_back_to_gbuffers_terrain() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_terrain_keeps_alpha_0_05_and_cutout_loses_it() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 0.05),
|
||||
("gbuffers_terrain_cutout", BLUE, 0.05),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(GREEN), None, Some(GREEN)]);
|
||||
// The solid part through its fallback, as the probe's `gbuffers_terrain` writing 0.05.
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 0.05),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
|
||||
}
|
||||
|
||||
/// With `separateAo` a vertex's alpha is its own ambient occlusion, so the corners of one quad
|
||||
/// differ; the quad still draws whole, with its part's program.
|
||||
#[test]
|
||||
fn a_quad_draws_whole_whatever_its_vertex_alphas() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
for alphas in [[255, 160, 255, 160], [128, 255, 200, 64], [160; 4]] {
|
||||
let Some(got) = strips_with_alphas(&programs, alphas) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)], "{alphas:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_with_neither_draws_all_terrain_with_gbuffers_terrain() {
|
||||
let Some(got) = strips(&[("gbuffers_terrain", RED, 1.0)]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some(RED); 3]);
|
||||
}
|
||||
|
||||
/// A pack whose only terrain program is `gbuffers_terrain_solid` (or `_cutout`) draws that part,
|
||||
/// and nothing draws the other: Iris 1.11.7 left it undrawn with our probes `tsc-solidonly` and
|
||||
/// `tsc-cutoutonly`.
|
||||
#[test]
|
||||
fn a_lone_split_program_draws_only_its_own_terrain() {
|
||||
let solid = strips(&[("gbuffers_terrain_solid", GREEN, 1.0)]);
|
||||
let Some(solid) = solid else { return };
|
||||
assert_eq!(solid, [Some(GREEN), None, Some(GREEN)]);
|
||||
let cutout = strips(&[("gbuffers_terrain_cutout", BLUE, 1.0)]);
|
||||
assert_eq!(cutout, Some([None, Some(BLUE), None]));
|
||||
}
|
||||
|
||||
/// When one part's pipeline can't be built (here a cutout program writing no buffer, which
|
||||
/// Horizon skips), the other part doesn't draw alone either: the game draws all terrain.
|
||||
#[test]
|
||||
fn a_part_that_fails_to_build_takes_the_other_with_it() {
|
||||
let mut files: Vec<(String, String)> = [
|
||||
program("gbuffers_terrain", RED, 1.0),
|
||||
program("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
]
|
||||
.concat();
|
||||
files.push((
|
||||
"gbuffers_terrain_cutout.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
|
||||
));
|
||||
files.push((
|
||||
"gbuffers_terrain_cutout.fsh".into(),
|
||||
"#version 120\nvoid main(){}".into(),
|
||||
));
|
||||
let Some((got, undrawn)) = strips_of(files, [255; 4]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [None; 3]);
|
||||
let notes = undrawn.expect("the game draws terrain");
|
||||
for note in [
|
||||
"gbuffers_terrain_cutout: writes no buffer; skipped",
|
||||
"gbuffers_terrain_solid: the other part of terrain can't draw",
|
||||
] {
|
||||
assert!(notes.iter().any(|n| n.starts_with(note)), "{notes:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Original synthetic weather oracle: no pack assets or external shader algorithms.
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut depth_failures = Vec::new();
|
||||
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
|
||||
for (property, writes) in [
|
||||
("rain.depth=true", true),
|
||||
("rain.depth=false", false),
|
||||
// An evidence-limited compatibility choice: preserve accepted main here.
|
||||
// The permitted packs do not establish the external omitted-value default.
|
||||
("", true),
|
||||
] {
|
||||
let mut post = pack(&device, &queue, &adapter, program, property);
|
||||
// Retain the pipelines for another frame, then resize their targets.
|
||||
for size in [[6, 1], [6, 1], [12, 2]] {
|
||||
post.resize(&device, size);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let targets = post.targets.as_ref().unwrap();
|
||||
let color = &targets.color[0].as_ref().unwrap()[0];
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[attach(
|
||||
color,
|
||||
wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &targets.depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.5),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
post.set_weather(Some(geometry(&device)));
|
||||
post.draw_weather(&device, &queue, &mut encoder, size);
|
||||
assert!(post.weather_draw.is_none(), "draw consumes this frame only");
|
||||
assert!(post.has_weather(), "pipelines survive the draw");
|
||||
// A dry frame must neither redraw old quads nor discard the pipelines.
|
||||
post.set_weather(None);
|
||||
post.draw_weather(&device, &queue, &mut encoder, size);
|
||||
assert!(post.has_weather());
|
||||
let targets = post.targets.as_ref().unwrap();
|
||||
let color = targets.color[0].as_ref().unwrap()[0].texture();
|
||||
let colors = readback(&device, &mut encoder, color);
|
||||
let depths = readback(&device, &mut encoder, &targets.depth_texture);
|
||||
queue.submit([encoder.finish()]);
|
||||
for buffer in [&colors, &depths] {
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
}
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let colors = colors.slice(..).get_mapped_range().unwrap();
|
||||
let depths = depths.slice(..).get_mapped_range().unwrap();
|
||||
for y in 0..size[1] as usize {
|
||||
for x in 0..size[0] as usize {
|
||||
let quad = x / (size[0] as usize / 6);
|
||||
let visible = quad.is_multiple_of(3);
|
||||
// First quad is in front; second is behind scene depth; third discards.
|
||||
let expected_color = if visible && quad < 3 {
|
||||
[128, 0, 127, 255]
|
||||
} else if visible {
|
||||
[0, 128, 127, 255]
|
||||
} else {
|
||||
[0, 0, 255, 255]
|
||||
};
|
||||
let offset = y * 256 + x * 4;
|
||||
let actual_color = &colors[offset..offset + 4];
|
||||
for (actual, expected) in actual_color.iter().zip(expected_color) {
|
||||
assert!(
|
||||
i16::from(*actual).abs_diff(expected) <= 1,
|
||||
"{program} {property:?} size={size:?} ({x},{y}): color {actual_color:?}"
|
||||
);
|
||||
}
|
||||
let depth =
|
||||
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
|
||||
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
|
||||
if depth != expected_depth {
|
||||
depth_failures.push(format!(
|
||||
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"Weather oracle {program} {property:?} {size:?}: color, occlusion and discard pass; cumulative depth mismatches={}",
|
||||
depth_failures.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(depth_failures.is_empty(), "{}", depth_failures.join("\n"));
|
||||
}
|
||||
|
||||
fn pack(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
program: &str,
|
||||
property: &str,
|
||||
) -> PackPost {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (extension, source) in [
|
||||
(
|
||||
"vsh",
|
||||
"#version 120\nvarying vec4 color; varying float marker; void main(){gl_Position=gl_Vertex;color=gl_Color;marker=gl_MultiTexCoord0.x;}",
|
||||
),
|
||||
(
|
||||
"fsh",
|
||||
"#version 120\nvarying vec4 color; varying float marker; /* DRAWBUFFERS:0 */ void main(){if(marker>1.5) discard;gl_FragData[0]=color;}",
|
||||
),
|
||||
] {
|
||||
zip.start_file(
|
||||
format!("shaders/{program}.{extension}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-weather-depth",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let macros = horizon_shaders::Macros::standard();
|
||||
let properties = horizon_shaders::Properties::parse(property, ¯os);
|
||||
let post = horizon_shaders::load_post(
|
||||
&pack,
|
||||
"",
|
||||
&properties,
|
||||
¯os,
|
||||
&horizon_shaders::GameInputs {
|
||||
weather: Some(super::super::pack_weather::inputs()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
assert_eq!(post.weather.as_ref().unwrap().name, program);
|
||||
let lightmap = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let mut post = PackPost::new(
|
||||
device,
|
||||
queue,
|
||||
adapter,
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap,
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: true,
|
||||
hand: false,
|
||||
},
|
||||
)
|
||||
.expect("weather pack runs");
|
||||
assert!(
|
||||
post.has_weather(),
|
||||
"production weather pipelines must compile: {:?}",
|
||||
post.notes
|
||||
);
|
||||
post.copied |= 1; // Keep colortex0 readable for the independent color oracle.
|
||||
post
|
||||
}
|
||||
|
||||
fn geometry(device: &wgpu::Device) -> Weather {
|
||||
let mut vertices = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
for quad in 0..6u32 {
|
||||
let left = quad as f32 / 3.0 - 1.0;
|
||||
let right = (quad + 1) as f32 / 3.0 - 1.0;
|
||||
let z = if quad % 3 == 1 { 0.5 } else { -0.5 };
|
||||
for pos in [
|
||||
[left, -1.0, z],
|
||||
[right, -1.0, z],
|
||||
[right, 1.0, z],
|
||||
[left, 1.0, z],
|
||||
] {
|
||||
vertices.push(super::super::Vertex {
|
||||
pos,
|
||||
uv: [(quad % 3) as f32, 0.0],
|
||||
layer: 0,
|
||||
color: u32::from_le_bytes(if quad < 3 {
|
||||
[255, 0, 0, 128]
|
||||
} else {
|
||||
[0, 255, 0, 128]
|
||||
}),
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
indices.extend([0, 1, 2, 0, 2, 3].map(|i| quad * 4 + i));
|
||||
}
|
||||
let buffer = |contents: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
Weather {
|
||||
vertices: buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX),
|
||||
indices: buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX),
|
||||
quads: [0..3, 3..6],
|
||||
sprites: [None, None],
|
||||
generation: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn readback(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(256 * texture.height()),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
aspect: if texture.format().is_depth_stencil_format() {
|
||||
wgpu::TextureAspect::DepthOnly
|
||||
} else {
|
||||
wgpu::TextureAspect::All
|
||||
},
|
||||
..texture.as_image_copy()
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
buffer
|
||||
}
|
||||
@@ -66,6 +66,10 @@ pub struct PackInputs {
|
||||
/// The player's cave mood, 0..1 (`BiomeAmbientSoundsHandler`'s moodiness):
|
||||
/// `playerMood` and `constantMood`.
|
||||
pub mood: f32,
|
||||
/// Under `HORIZON_STILL`, the world's ticks: the pack's clock (`frameTimeCounter`,
|
||||
/// `frameCounter`) instead of the wall clock and the frames drawn, and smoothed values
|
||||
/// (`wetness`, `eyeBrightnessSmooth`, `centerDepthSmooth`) at their targets.
|
||||
pub still_ticks: Option<u64>,
|
||||
}
|
||||
|
||||
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
|
||||
@@ -299,6 +303,8 @@ pub struct FrameUniforms {
|
||||
start: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frame: u32,
|
||||
/// The last frame's `PackInputs::still_ticks`, for `eval_custom`.
|
||||
still_ticks: Option<u64>,
|
||||
previous: Option<(glam::DVec3, Mat4, Mat4)>,
|
||||
wetness: f32,
|
||||
eye_smooth: [f32; 2],
|
||||
@@ -317,6 +323,7 @@ impl Default for FrameUniforms {
|
||||
start: std::time::Instant::now(),
|
||||
last: None,
|
||||
frame: 0,
|
||||
still_ticks: None,
|
||||
previous: None,
|
||||
wetness: 0.0,
|
||||
eye_smooth: [0.0; 2],
|
||||
@@ -422,8 +429,11 @@ impl FrameUniforms {
|
||||
/// This frame's values for a render size of `size` pixels.
|
||||
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
|
||||
let measured = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
self.still_ticks = inputs.still_ticks;
|
||||
// Smoothing over an endless step lands on its target.
|
||||
let dt = inputs.still_ticks.map_or(measured, |_| f32::INFINITY);
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
// Packs take gbufferProjection for a plain perspective (they invert only its perspective
|
||||
// terms): the view bob and the nausea spin belong to the model-view, as GL drew them.
|
||||
@@ -502,7 +512,18 @@ impl FrameUniforms {
|
||||
let (shadow_view, shadow_projection) = shadow_camera(light, frame.camera, &self.shadow);
|
||||
let day_time = inputs.day_time.rem_euclid(24000) as i32;
|
||||
let world_day = inputs.day_time.div_euclid(24000);
|
||||
let seconds = (now - self.start).as_secs_f32() % 3600.0;
|
||||
let (seconds, frame_time, frame_counter) = match inputs.still_ticks {
|
||||
Some(ticks) => (
|
||||
(ticks as f32 / 20.0) % 3600.0,
|
||||
0.05,
|
||||
(ticks % 720_720) as i32,
|
||||
),
|
||||
None => (
|
||||
(now - self.start).as_secs_f32() % 3600.0,
|
||||
measured,
|
||||
self.frame as i32,
|
||||
),
|
||||
};
|
||||
|
||||
let value = |name: &str| -> Value {
|
||||
let v3 = |v: Vec3| Value::f(&v.to_array());
|
||||
@@ -538,13 +559,13 @@ impl FrameUniforms {
|
||||
"worldTime" => Value::i(&[day_time]),
|
||||
"worldDay" => Value::i(&[world_day as i32]),
|
||||
"moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]),
|
||||
"frameCounter" => Value::i(&[self.frame as i32]),
|
||||
"frameCounter" => Value::i(&[frame_counter]),
|
||||
"shadowModelView" => m4(shadow_view),
|
||||
"shadowModelViewInverse" => m4(shadow_view.inverse()),
|
||||
"shadowProjection" => m4(shadow_projection),
|
||||
"shadowProjectionInverse" => m4(shadow_projection.inverse()),
|
||||
"atlasSize" => Value::i(&[self.atlas[0] as i32, self.atlas[1] as i32]),
|
||||
"frameTime" => Value::f(&[dt]),
|
||||
"frameTime" => Value::f(&[frame_time]),
|
||||
"frameTimeCounter" => Value::f(&[seconds]),
|
||||
"viewWidth" => Value::f(&[size[0] as f32]),
|
||||
"viewHeight" => Value::f(&[size[1] as f32]),
|
||||
@@ -631,6 +652,9 @@ impl FrameUniforms {
|
||||
/// Evaluates the pack's custom uniforms with this frame's values and puts them after
|
||||
/// `NAMES`' values, in `custom.uniforms()` order.
|
||||
pub fn eval_custom(&mut self, custom: &mut horizon_shaders::Custom) {
|
||||
if let Some(tick) = self.still_ticks {
|
||||
custom.restart_at(tick);
|
||||
}
|
||||
self.values.truncate(NAMES.len());
|
||||
let values = &self.values;
|
||||
let builtin = |name: &str| {
|
||||
@@ -1396,6 +1420,42 @@ mod initial_history_tests {
|
||||
assert!((float(&u, "wetness")[0] - 0.25).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// Under `HORIZON_STILL` the pack's clock is the world's ticks, whenever and however often
|
||||
/// the frame is drawn, and smoothed values are at their targets.
|
||||
#[test]
|
||||
fn a_still_frame_takes_the_world_ticks_as_its_clock() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
let mut input = PackInputs {
|
||||
eye_light: [0, 15],
|
||||
still_ticks: Some(47_811),
|
||||
..Default::default()
|
||||
};
|
||||
u.center_depth_raw = Some(1.0);
|
||||
u.update(&f, &input, [800, 450]);
|
||||
input.eye_light = [0, 0];
|
||||
input.rain = 1.0;
|
||||
u.center_depth_raw = Some(0.0);
|
||||
u.last = Some(std::time::Instant::now() - std::time::Duration::from_secs(3));
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert_eq!(
|
||||
float(&u, "frameTimeCounter")[0],
|
||||
(47_811.0f32 / 20.0) % 3600.0
|
||||
);
|
||||
assert_eq!(float(&u, "frameTime")[0], 0.05);
|
||||
match u.values[index("frameCounter").unwrap()] {
|
||||
Value::I(v) => assert_eq!(v[0], 47_811),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
match u.values[index("eyeBrightnessSmooth").unwrap()] {
|
||||
Value::I(v) => assert_eq!(v[1], 0),
|
||||
_ => panic!("integer uniform expected"),
|
||||
}
|
||||
assert_eq!(float(&u, "wetness")[0], 1.0);
|
||||
assert_eq!(float(&u, "centerDepthSmooth")[0], 0.0);
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 (probe pack): `ambientLight` is the dimension type's (0.1 in the Nether,
|
||||
/// 0.25 in the End), and `playerMood` and `constantMood` both the cave mood.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! NORMAL world text: alpha blended, back-face culled, ordinary depth writes.
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
layout: Option<&wgpu::PipelineLayout>,
|
||||
shader: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Normal world text"),
|
||||
layout,
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
#[test]
|
||||
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
|
||||
fn world_text_blends_culls_and_obeys_world_depth() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
// Use the production pipeline/vertex layout with a pass-through shader
|
||||
// to isolate its actual depth, winding and blend behavior from lighting.
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
r#"
|
||||
struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
|
||||
@vertex fn vs_entity(@location(0) pos:vec3<f32>, @location(3) color:vec4<f32>)->Out {
|
||||
return Out(vec4(pos,1.0),color);
|
||||
}
|
||||
@fragment fn fs_entity(v:Out)->@location(0) vec4<f32> { return v.color; }
|
||||
"#
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = pipeline(&device, None, &shader, wgpu::TextureFormat::Rgba8Unorm);
|
||||
let texture = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 4,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = texture(
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = texture(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let mut vertices = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
let mut quad = |pixel: u32, z: f32, color: [u8; 4], back: bool| {
|
||||
let x = -1.0 + pixel as f32 * 0.5;
|
||||
let first = vertices.len() as u32;
|
||||
for pos in [
|
||||
[x, -1.0, z],
|
||||
[x + 0.5, -1.0, z],
|
||||
[x + 0.5, 1.0, z],
|
||||
[x, 1.0, z],
|
||||
] {
|
||||
vertices.push(super::super::Vertex {
|
||||
pos,
|
||||
uv: [0.0; 2],
|
||||
color: u32::from_le_bytes(color),
|
||||
layer: 0,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
for i in if back {
|
||||
[0, 2, 1, 0, 3, 2]
|
||||
} else {
|
||||
[0, 1, 2, 0, 2, 3]
|
||||
} {
|
||||
indices.push(first + i);
|
||||
}
|
||||
};
|
||||
quad(0, 0.6, [255, 0, 0, 255], false);
|
||||
// A hair behind: the entity model pipeline's 128-ulp depth bias
|
||||
// would incorrectly put the green quad in front of the red surface.
|
||||
quad(0, 0.599_999, [0, 255, 0, 255], false);
|
||||
quad(1, 0.8, [0, 255, 0, 255], false);
|
||||
quad(1, 0.7, [255, 0, 0, 255], false);
|
||||
quad(2, 0.5, [255, 0, 0, 128], false);
|
||||
quad(3, 0.5, [255, 255, 0, 255], true);
|
||||
let buffer = |data: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: data,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
let vertex = buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX);
|
||||
let index = buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX);
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let color = color.create_view(&Default::default());
|
||||
let depth = depth.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_vertex_buffer(0, vertex.slice(..));
|
||||
pass.set_index_buffer(index.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..indices.len() as u32, 0, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
color.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
readback.map_async(wgpu::MapMode::Read, .., move |result| {
|
||||
tx.send(result).unwrap()
|
||||
});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
rx.recv().unwrap().unwrap();
|
||||
let pixels = readback.get_mapped_range(..).unwrap();
|
||||
assert_eq!(
|
||||
&pixels[..16],
|
||||
&[
|
||||
255, 0, 0, 255, 0, 255, 0, 255, 128, 0, 127, 255, 0, 0, 255, 255
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -532,6 +532,52 @@ pub fn smooth_gradients_offered() -> bool {
|
||||
SMOOTH_GRADIENTS_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Whether GPU Culling (`Graphics::gpu_culling`) can be switched on here, and why not: set when a
|
||||
/// renderer starts, and again when its pass can't be set up or its buffers stop fitting in video
|
||||
/// memory. Where it isn't offered the settings grey its switch out with the reason, and the saved
|
||||
/// value waits for a renderer that offers it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GpuCullingOffer {
|
||||
/// No count draws the GPU wrote, compute shaders or working first instances (OpenGL, Metal,
|
||||
/// Vulkan without `VK_KHR_draw_indirect_count`, Intel Gen11/Gen12's Vulkan driver), vanilla
|
||||
/// frames drawn flat, or a pass the driver couldn't build.
|
||||
Unsupported = 0,
|
||||
/// A software adapter (WARP, lavapipe): the pass would run on the CPU, beside the frame.
|
||||
Software = 1,
|
||||
/// Its buffers stopped fitting in video memory: off until the next renderer.
|
||||
OutOfMemory = 2,
|
||||
/// DX12 with flat page groups, whose count draws leave each draw's base vertex out of
|
||||
/// what the near sections' packed faces are read by (`gpu_culling::loses_base_vertex`).
|
||||
Dx12 = 3,
|
||||
Offered = 4,
|
||||
}
|
||||
impl GpuCullingOffer {
|
||||
/// The greyed switch's tooltip; `None` where it is offered.
|
||||
pub fn reason(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Unsupported => Some("Your GPU or graphics API doesn't support it."),
|
||||
Self::Software => Some("Not with software rendering, which runs it on the CPU."),
|
||||
Self::OutOfMemory => Some("Out of video memory: off until the next start."),
|
||||
Self::Dx12 => Some("Not on DirectX 12 yet: try the Vulkan graphics API."),
|
||||
Self::Offered => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
static GPU_CULLING_OFFER: std::sync::atomic::AtomicU8 =
|
||||
std::sync::atomic::AtomicU8::new(GpuCullingOffer::Unsupported as u8);
|
||||
pub fn offer_gpu_culling(offer: GpuCullingOffer) {
|
||||
GPU_CULLING_OFFER.store(offer as u8, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn gpu_culling_offer() -> GpuCullingOffer {
|
||||
match GPU_CULLING_OFFER.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
1 => GpuCullingOffer::Software,
|
||||
2 => GpuCullingOffer::OutOfMemory,
|
||||
3 => GpuCullingOffer::Dx12,
|
||||
4 => GpuCullingOffer::Offered,
|
||||
_ => GpuCullingOffer::Unsupported,
|
||||
}
|
||||
}
|
||||
|
||||
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
|
||||
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
|
||||
/// costs them more frame time than it is worth.
|
||||
@@ -608,6 +654,11 @@ pub struct Graphics {
|
||||
pub blue_map: crate::bluemap::Settings,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Near solid terrain culled, and its draws listed, on the GPU (`gfx::gpu_culling`), where it
|
||||
/// is offered (`gpu_culling_offer`). Off by default (GPU Culling, Performance graphics); it
|
||||
/// applies at once, and no preset sets it. Every save writes it, so a default turned on later
|
||||
/// needs a new key, as `batchTranslucent` did.
|
||||
pub gpu_culling: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
|
||||
pub entity_culling: EntityCullingMode,
|
||||
/// Skip particles outside the view or hidden behind opaque terrain.
|
||||
@@ -716,6 +767,7 @@ impl Default for Graphics {
|
||||
far_detail: FarDetail::Full,
|
||||
blue_map: crate::bluemap::Settings::default(),
|
||||
cave_culling: true,
|
||||
gpu_culling: false,
|
||||
entity_culling: EntityCullingMode::Off,
|
||||
particle_culling: ParticleCullingMode::Off,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
@@ -812,6 +864,7 @@ impl Graphics {
|
||||
}
|
||||
"blueMapAutoDetect" => g.blue_map.auto_detect = flag(g.blue_map.auto_detect),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"gpuCulling" => g.gpu_culling = flag(g.gpu_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"particleCulling" => g.particle_culling = ParticleCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
@@ -906,7 +959,7 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
let mut text = format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrLighting:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\ngpuCulling:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
@@ -959,6 +1012,7 @@ impl Graphics {
|
||||
self.shader_pack,
|
||||
bool_text(self.translucent_batching),
|
||||
bool_text(self.hdr_smooth_gradients),
|
||||
bool_text(self.gpu_culling),
|
||||
);
|
||||
text += &self.blue_map.serialize();
|
||||
text
|
||||
@@ -1097,6 +1151,7 @@ mod tests {
|
||||
servers: Vec::new(),
|
||||
},
|
||||
cave_culling: false,
|
||||
gpu_culling: true,
|
||||
entity_culling: EntityCullingMode::Synchronous,
|
||||
particle_culling: ParticleCullingMode::Asynchronous,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
@@ -1183,6 +1238,9 @@ mod tests {
|
||||
assert!(Graphics::parse("hdr:true\n").wide_frame());
|
||||
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
|
||||
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
|
||||
// GPU Culling is opt-in: only the file or its switch turns it on.
|
||||
assert!(!Graphics::default().gpu_culling);
|
||||
assert!(Graphics::parse("gpuCulling:true\n").gpu_culling);
|
||||
// 0 is Auto; anything else is a peak the slider reaches.
|
||||
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
|
||||
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
|
||||
|
||||
@@ -65,6 +65,7 @@ pub enum H {
|
||||
BlueMapAutoDetect,
|
||||
BlueMapRadius,
|
||||
CaveCulling,
|
||||
GpuCulling,
|
||||
EntityCulling,
|
||||
ParticleCulling,
|
||||
LeafCulling,
|
||||
@@ -377,6 +378,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
Saves,
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
),
|
||||
h(
|
||||
H::GpuCulling,
|
||||
Saves,
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.",
|
||||
),
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
@@ -551,15 +557,15 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
],
|
||||
),
|
||||
];
|
||||
// Only Windows and Linux have more than one graphics API, and translucent batching
|
||||
// (Metal draws flat).
|
||||
// Only Windows and Linux have more than one graphics API, translucent batching (Metal
|
||||
// draws flat) and GPU culling (Metal draws no count the GPU wrote).
|
||||
if cfg!(target_os = "macos") {
|
||||
for (_, groups) in &mut pages {
|
||||
for (_, rows) in groups {
|
||||
rows.retain(|r| {
|
||||
!matches!(
|
||||
r.item,
|
||||
Item::Horizon(H::GraphicsApi | H::TranslucentBatching)
|
||||
Item::Horizon(H::GraphicsApi | H::TranslucentBatching | H::GpuCulling)
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -748,6 +754,12 @@ impl H {
|
||||
lang::tr("%s chunks", &[&g.blue_map.radius_chunks()]),
|
||||
),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
// Greyed out where it isn't offered (`Item::blocked`): Off, as it is there, while
|
||||
// the saved value waits.
|
||||
H::GpuCulling => labelled(
|
||||
"GPU Culling",
|
||||
on_off(g.gpu_culling && crate::graphics::gpu_culling_offer().reason().is_none()),
|
||||
),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::ParticleCulling => labelled("Particle Culling", g.particle_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
@@ -904,6 +916,7 @@ impl H {
|
||||
g.blue_map.radius = step(&BLUE_MAP_RADII, g.blue_map.radius_chunks(), back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::GpuCulling => g.gpu_culling = !g.gpu_culling,
|
||||
H::EntityCulling => {
|
||||
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
|
||||
}
|
||||
@@ -993,7 +1006,8 @@ impl Item {
|
||||
/// the saved values wait for an HDR display), and its sliders do something only
|
||||
/// while it is on; Translucent Batching switches only where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// and Smooth Gradients only where it can draw the 16-bit frame, the same way.
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
fn blocked(self, s: &Settings) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -1030,6 +1044,7 @@ impl Item {
|
||||
{
|
||||
Some("Your GPU or graphics API doesn't support it.")
|
||||
}
|
||||
Item::Horizon(H::GpuCulling) => crate::graphics::gpu_culling_offer().reason(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1045,6 +1060,9 @@ impl Item {
|
||||
Some(match self {
|
||||
Item::Horizon(H::BlueMapAutoDetect) => g.blue_map.auto_detect,
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::GpuCulling) => {
|
||||
g.gpu_culling && crate::graphics::gpu_culling_offer().reason().is_none()
|
||||
}
|
||||
Item::Horizon(H::CacheShulkerBoxes) => g.cache_shulker_boxes,
|
||||
Item::Horizon(H::CacheSigns) => g.cache_signs,
|
||||
Item::Horizon(H::CacheDecoratedPots) => g.cache_decorated_pots,
|
||||
@@ -1134,6 +1152,7 @@ impl Item {
|
||||
| H::GraphicsApi
|
||||
| H::TranslucentBatching
|
||||
| H::HdrSmoothGradients
|
||||
| H::GpuCulling
|
||||
) | Item::Vanilla(
|
||||
Opt::GuiScale
|
||||
| Opt::Fullscreen
|
||||
@@ -1689,6 +1708,9 @@ impl GraphicsUi {
|
||||
crate::widgets::widget_cursor(hovered(rect), true);
|
||||
crate::widgets::target(rect);
|
||||
let primary = target == Target::Done;
|
||||
if primary {
|
||||
crate::widgets::declare_action(crate::widgets::Action::Done, rect, true);
|
||||
}
|
||||
style::button(batch, atlas, rect, &label, (hover, true, primary));
|
||||
}
|
||||
}
|
||||
@@ -3212,6 +3234,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// GPU Culling: a switch right after Cave Culling, off by default, no part of a preset,
|
||||
/// and greyed out with its reason, reading Off, wherever it isn't offered.
|
||||
#[test]
|
||||
fn gpu_culling_is_a_culling_switch_where_it_is_offered() {
|
||||
use crate::graphics::GpuCullingOffer;
|
||||
let item = Item::Horizon(H::GpuCulling);
|
||||
let at = |item| items().into_iter().position(|(_, i)| i == item);
|
||||
if cfg!(target_os = "macos") {
|
||||
// Metal draws no count the GPU wrote: never offered, so not shown.
|
||||
assert_eq!(at(item), None);
|
||||
return;
|
||||
}
|
||||
let i = at(item).unwrap();
|
||||
assert_eq!(items()[i].0, Page::Performance);
|
||||
assert_eq!(at(Item::Horizon(H::CaveCulling)), Some(i - 1));
|
||||
let mut s = Settings::default();
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
s.horizon.gpu_culling = true;
|
||||
// Where it isn't offered, the saved On reads Off, and neither a click nor Enter after
|
||||
// it does anything.
|
||||
for offer in [
|
||||
GpuCullingOffer::Unsupported,
|
||||
GpuCullingOffer::Software,
|
||||
GpuCullingOffer::OutOfMemory,
|
||||
GpuCullingOffer::Dx12,
|
||||
] {
|
||||
crate::graphics::offer_gpu_culling(offer);
|
||||
assert!(offer.reason().is_some());
|
||||
assert_eq!(item.blocked(&s), offer.reason());
|
||||
assert_eq!(item.label(&s).plain(), "GPU Culling: Off");
|
||||
// The box, the narrator and the widget tree read the switch.
|
||||
assert_eq!(item.switch(&s), Some(false), "{offer:?}");
|
||||
let mut ui = GraphicsUi::default();
|
||||
let r = row(&mut ui, &s, item);
|
||||
assert!(press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)).is_empty());
|
||||
ui.focus = Some(Target::Item(item));
|
||||
let enter = ui.key_modifiers(&mut s, K::Enter, None, false, SIZE, FB);
|
||||
assert!(enter.0.is_empty());
|
||||
assert!(s.horizon.gpu_culling, "{offer:?}");
|
||||
}
|
||||
// Where it is, a click switches it and keeps the preset.
|
||||
crate::graphics::offer_gpu_culling(GpuCullingOffer::Offered);
|
||||
assert_eq!(item.blocked(&s), None);
|
||||
assert_eq!(item.label(&s).plain(), "GPU Culling: On");
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
let mut ui = GraphicsUi::default();
|
||||
let r = row(&mut ui, &s, item);
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, (r[0] + 4.0, r[1] + 4.0)),
|
||||
[Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert!(!s.horizon.gpu_culling);
|
||||
assert_eq!(s.horizon.preset, Preset::Balanced);
|
||||
// No preset sets it.
|
||||
for on in [false, true] {
|
||||
s.horizon.gpu_culling = on;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.gpu_culling, on, "{preset:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_cycles_modes_beside_the_other_culling() {
|
||||
let mut s = Settings::default();
|
||||
@@ -3396,6 +3482,11 @@ mod tests {
|
||||
] {
|
||||
lang::get(text);
|
||||
}
|
||||
// Every reason GPU Culling's switch can give, whatever this device offers.
|
||||
use crate::graphics::GpuCullingOffer as O;
|
||||
for offer in [O::Unsupported, O::Software, O::OutOfMemory, O::Dx12] {
|
||||
lang::get(offer.reason().unwrap());
|
||||
}
|
||||
});
|
||||
// `labelled` also looks up values that are already words in the language ("An") or
|
||||
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
|
||||
|
||||
@@ -207,6 +207,16 @@ impl Calibration {
|
||||
(l.next, if last { save } else { next }, true),
|
||||
] {
|
||||
let hover = over(cursor, rect) && active;
|
||||
let action = if rect == l.cancel {
|
||||
Some(crate::widgets::Action::Cancel)
|
||||
} else if rect == l.back {
|
||||
Some(crate::widgets::Action::Back)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(action) = action {
|
||||
crate::widgets::declare_action(action, [rect.0, rect.1, rect.2, 20.0], active);
|
||||
}
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
|
||||
@@ -256,7 +256,15 @@ pub fn decode_image_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
match ImageFormat::sniff(data) {
|
||||
Some(ImageFormat::WebP) => decode_webp(data),
|
||||
Some(ImageFormat::Jpeg) => decode_jpeg(data),
|
||||
Some(ImageFormat::Png) | None => decode_bytes(data),
|
||||
// A PNG's IHDR states the size before the decoder sizes its pixel
|
||||
// buffer, so it is checked there like the WebP and JPEG headers: the
|
||||
// PNG reader alone would trust the header and allocate it.
|
||||
Some(ImageFormat::Png) | None => {
|
||||
if let Some((width, height)) = png_bytes_size(data) {
|
||||
check_fetched_size(u64::from(width), u64::from(height))?;
|
||||
}
|
||||
decode_bytes(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,15 +316,19 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
|
||||
.map(|p| (*p).clone())
|
||||
.collect()
|
||||
}
|
||||
/// A PNG's width and height from its first 24 bytes (`IHDR`), before any
|
||||
/// pixel buffer is made.
|
||||
fn png_bytes_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
let header = data.get(..24)?;
|
||||
if header[..8] != *b"\x89PNG\r\n\x1a\n" || header[12..16] != *b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
Some((field(16), field(20)))
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
|
||||
.then(|| (field(16), field(20)))
|
||||
png_bytes_size(&horizon_core::prepared_files::read_head(path, 24).ok()?)
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
@@ -1928,7 +1940,7 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, false)
|
||||
self.styled_text(atlas, text, x, y, color, shadow, false, None)
|
||||
}
|
||||
/// `text_styled` for text drawn in the world (`TextRenderable.render`
|
||||
/// with `flat` false): each quad's `depth` is its z in text units, the
|
||||
@@ -1944,7 +1956,42 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, true)
|
||||
self.styled_text(atlas, text, x, y, color, shadow, true, None)
|
||||
}
|
||||
/// `Font.drawInBatch8xOutline`: eight glyph offsets in the outline color,
|
||||
/// then the original text. Returns (advance, first front-text vertex), so
|
||||
/// world rendering can use POLYGON_OFFSET for the front and NORMAL for
|
||||
/// its outline. Offsets use each glyph's own shadow offset (unihex: 0.5).
|
||||
pub fn text_styled_outline_world(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
color: u32,
|
||||
outline: u32,
|
||||
) -> (f32, usize) {
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if dx != 0 || dy != 0 {
|
||||
self.styled_text(
|
||||
atlas,
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
false,
|
||||
true,
|
||||
Some((outline, [dx as f32, dy as f32])),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let front = self.vertices.len();
|
||||
(
|
||||
self.styled_text(atlas, text, x, y, color, false, true, None),
|
||||
front,
|
||||
)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn styled_text(
|
||||
@@ -1956,6 +2003,7 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
world: bool,
|
||||
outline: Option<(u32, [f32; 2])>,
|
||||
) -> f32 {
|
||||
// StringDecomposer.iterateFormatted inside Font.drawInternal:
|
||||
// `§` codes inside a span restyle what follows and are not drawn.
|
||||
@@ -2023,10 +2071,11 @@ impl HudBatch {
|
||||
}
|
||||
self.patches.extend(font.take_patches());
|
||||
let white = font.white();
|
||||
let has_shadow = shadow
|
||||
|| glyphs
|
||||
.iter()
|
||||
.any(|glyph| glyph.3.is_some_and(|color| color != 0));
|
||||
let has_shadow = outline.is_none()
|
||||
&& (shadow
|
||||
|| glyphs
|
||||
.iter()
|
||||
.any(|glyph| glyph.3.is_some_and(|color| color != 0)));
|
||||
let passes: &[bool] = if has_shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the default shadow colour.
|
||||
let shade = |c: u32| {
|
||||
@@ -2061,8 +2110,9 @@ impl HudBatch {
|
||||
} else {
|
||||
shade(*c)
|
||||
};
|
||||
let has_shadow = (custom_shadow.is_some_and(|argb| argb != 0)
|
||||
|| custom_shadow.is_none() && shadow)
|
||||
let has_shadow = outline.is_none()
|
||||
&& (custom_shadow.is_some_and(|argb| argb != 0)
|
||||
|| custom_shadow.is_none() && shadow)
|
||||
&& !(world && shadow_color == 0);
|
||||
if is_shadow && !has_shadow {
|
||||
continue;
|
||||
@@ -2072,7 +2122,11 @@ impl HudBatch {
|
||||
} else {
|
||||
(0.0, *c)
|
||||
};
|
||||
let (px, py) = (*gx + dx, y + dx);
|
||||
let (px, py, col) = if let Some((color, [dx, dy])) = outline {
|
||||
(*gx + dx * g.offset, y + dy * g.offset, color)
|
||||
} else {
|
||||
(*gx + dx, y + dx, col)
|
||||
};
|
||||
if let Some(object) = object {
|
||||
// `PlainTextRenderable.render`: sprite glyphs use the same
|
||||
// shadow colour and offset as text, with the front at SHADOW_DEPTH.
|
||||
@@ -2916,6 +2970,41 @@ mod tests {
|
||||
assert!(check_fetched_size(16_383, 16_383).is_err());
|
||||
}
|
||||
|
||||
/// A fetched PNG is refused by the size its own header claims, before the
|
||||
/// reader makes the pixel buffer: a few hundred bytes of payload would
|
||||
/// otherwise ask for 64 MiB or much more.
|
||||
#[test]
|
||||
fn a_fetched_png_is_refused_by_the_size_its_header_claims() {
|
||||
let mut encoded = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut encoded, 2, 2);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&[7; 16]).unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
assert!(matches!(
|
||||
ImageFormat::sniff(&encoded),
|
||||
Some(ImageFormat::Png)
|
||||
));
|
||||
assert_eq!(
|
||||
decode_image_bytes(&encoded).expect("a small PNG decodes").0,
|
||||
2
|
||||
);
|
||||
|
||||
// The same image, its IHDR claiming one pixel row past the limit: the
|
||||
// CRC is ignored on purpose, so this stands in for a hostile header.
|
||||
let ihdr = encoded.windows(4).position(|w| w == b"IHDR").expect("IHDR");
|
||||
encoded[ihdr + 4..ihdr + 8].copy_from_slice(&4097u32.to_be_bytes());
|
||||
encoded[ihdr + 8..ihdr + 12].copy_from_slice(&4096u32.to_be_bytes());
|
||||
let refused = decode_image_bytes(&encoded).expect_err("the size is refused");
|
||||
assert!(
|
||||
refused.to_string().contains("too large to decode"),
|
||||
"{refused}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only the network path reads WebP and JPEG: a resource pack's texture
|
||||
/// stays PNG-only, as vanilla's `NativeImage.read` is.
|
||||
#[test]
|
||||
@@ -3819,6 +3908,44 @@ texture=wide",
|
||||
assert_eq!(fonts.font.width("abba"), width);
|
||||
}
|
||||
#[test]
|
||||
fn outlined_world_text_keeps_font_metrics_and_overrides_outline_color() {
|
||||
let root = two_glyph_font("world-outline");
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let text = crate::text::Text::colored("ab", 0xff0000);
|
||||
let outline = rgba(1, 2, 3, 128);
|
||||
let (advance, front) =
|
||||
batch.text_styled_outline_world(&atlas, &text, 10.0, 20.0, u32::MAX, outline);
|
||||
assert_eq!((advance, front, batch.vertices.len()), (10.0, 96, 108));
|
||||
assert!(batch.vertices[..front].iter().all(|v| v.color == outline));
|
||||
assert!(
|
||||
batch.vertices[front..]
|
||||
.iter()
|
||||
.all(|v| v.color == rgba(255, 0, 0, 255))
|
||||
);
|
||||
// Eight independently offset silhouettes, retaining both glyphs and
|
||||
// the bitmap font's advance (a=4, b=6); front text has no shadow.
|
||||
for (i, [dx, dy]) in [
|
||||
[-1.0, -1.0],
|
||||
[-1.0, 0.0],
|
||||
[-1.0, 1.0],
|
||||
[0.0, -1.0],
|
||||
[0.0, 1.0],
|
||||
[1.0, -1.0],
|
||||
[1.0, 0.0],
|
||||
[1.0, 1.0],
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let at = batch.vertices[i * 12].pos;
|
||||
let main = batch.vertices[front].pos;
|
||||
assert_eq!(at, [main[0] + dx, main[1] + dy]);
|
||||
assert_eq!(batch.vertices[i * 12 + 6].pos[0] - at[0], 4.0);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn obfuscated_glyphs_keep_the_width() {
|
||||
let root = two_glyph_font("obfuscated");
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
|
||||
@@ -354,7 +354,9 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
|
||||
if !fluid(top) {
|
||||
spans.push(ground(top));
|
||||
} else if let Some(under) = below.iter().rev().find(|&span| !fluid(span)) {
|
||||
// Shaded water is a span per light level; the bed is under all of it.
|
||||
// The bed is under all of the water, however many spans the water
|
||||
// takes: a chunk's own columns shade it a span a light level, a
|
||||
// previewed far column one span for the run.
|
||||
spans.push(ground(under));
|
||||
spans.push(Span {
|
||||
min: under.max,
|
||||
|
||||
@@ -3624,9 +3624,10 @@ impl OptionsUi {
|
||||
true,
|
||||
);
|
||||
}
|
||||
ui::button(
|
||||
ui::action_button(
|
||||
batch,
|
||||
atlas,
|
||||
crate::widgets::Action::Done,
|
||||
w / 2.0 - 100.0,
|
||||
h - 28.0,
|
||||
200.0,
|
||||
@@ -3780,9 +3781,24 @@ impl OptionsUi {
|
||||
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
|
||||
}
|
||||
match &wd.kind {
|
||||
WKind::Button { label, active, .. } => {
|
||||
WKind::Button { label, active, act } => {
|
||||
let label = fitted.as_ref().map_or(label, |f| &f.0);
|
||||
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
|
||||
if matches!(act, Act::Done) {
|
||||
ui::action_button(
|
||||
batch,
|
||||
atlas,
|
||||
crate::widgets::Action::Done,
|
||||
wd.x,
|
||||
wd.y,
|
||||
wd.w,
|
||||
wd.h,
|
||||
label,
|
||||
*active,
|
||||
hover,
|
||||
);
|
||||
} else {
|
||||
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
|
||||
}
|
||||
crate::widgets::last_narratable(|n| {
|
||||
narrate_widget(n, s, wd, hover, list_key, framebuffer);
|
||||
});
|
||||
|
||||
@@ -1161,6 +1161,11 @@ impl PackScreen {
|
||||
d::fill(out, 0, 0, self.width, TOPBAR_H, SIDEBAR_BG);
|
||||
let (bx, by) = self.back_button();
|
||||
let back_hover = d::inside(mouse, bx, by, BACK_W, BACK_H);
|
||||
crate::widgets::declare_action(
|
||||
crate::widgets::Action::Back,
|
||||
[bx as f32, by as f32, BACK_W as f32, BACK_H as f32],
|
||||
true,
|
||||
);
|
||||
d::button(
|
||||
out,
|
||||
m,
|
||||
@@ -1377,6 +1382,13 @@ impl PackScreen {
|
||||
let hover = d::inside(mouse, bx, by, 150, 20);
|
||||
// The cursor follows the mouse; the highlight also keyboard focus.
|
||||
crate::widgets::widget_cursor(hover, true);
|
||||
if i == 1 && self.browser.is_none() {
|
||||
crate::widgets::declare_action(
|
||||
crate::widgets::Action::Done,
|
||||
[bx as f32, by as f32, 150.0, 20.0],
|
||||
true,
|
||||
);
|
||||
}
|
||||
d::button(
|
||||
out,
|
||||
m,
|
||||
|
||||
@@ -709,15 +709,24 @@ pub fn lookup(
|
||||
];
|
||||
for (kind, url) in urls {
|
||||
let Some(url) = url else { continue };
|
||||
// registerTexture computes the hash first; a bad URL fails the lookup.
|
||||
let hash = texture_hash(url)?;
|
||||
// registerTexture computes the hash first. A cape or elytra URL the
|
||||
// hash cannot take is that texture's problem alone: vanilla's lookup
|
||||
// goes on to the next, and only the skin texture decides the default.
|
||||
let Some(hash) = texture_hash(url) else {
|
||||
if kind == Kind::Skin {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if kind == Kind::Skin {
|
||||
skin = Some(skin_location(&hash));
|
||||
}
|
||||
match download(kind, url) {
|
||||
Download::Failed => return None,
|
||||
// A cape or elytra that cannot be fetched is simply not drawn; only
|
||||
// the skin itself falls back to the default one (`SkinManager.get`).
|
||||
Download::Failed if kind == Kind::Skin => return None,
|
||||
Download::Failed | Download::Ready => {}
|
||||
Download::Pending => state = Download::Pending,
|
||||
Download::Ready => {}
|
||||
}
|
||||
}
|
||||
if state != Download::Ready || require_secure && !textures.signed {
|
||||
@@ -809,6 +818,12 @@ pub struct Downloader {
|
||||
resource_root: std::path::PathBuf,
|
||||
/// How each `patch/` texture of the resource packs loaded, by location.
|
||||
resources: std::collections::HashMap<String, Download>,
|
||||
/// When each failed texture download was last tried, so it can be retried:
|
||||
/// a transient network error must not pin a player to the default skin for
|
||||
/// the rest of the session.
|
||||
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
|
||||
/// How long a failed texture download waits before it is tried again.
|
||||
retry_after: std::time::Duration,
|
||||
}
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
@@ -839,6 +854,8 @@ impl Downloader {
|
||||
drawn: Default::default(),
|
||||
resource_root: Default::default(),
|
||||
resources: Default::default(),
|
||||
failed: Default::default(),
|
||||
retry_after: std::time::Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
/// The resource packs changed (or were first loaded) at `root`: patch
|
||||
@@ -879,7 +896,8 @@ impl Downloader {
|
||||
};
|
||||
self.resources.insert(location.to_owned(), state);
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time.
|
||||
/// How the download of `url` stands, starting it the first time. A
|
||||
/// failure is permanent for `retry_after`, then tried again.
|
||||
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
let state = match image {
|
||||
@@ -889,9 +907,13 @@ impl Downloader {
|
||||
Kind::Cape => self.queue(cape_location(&key.1), rgba),
|
||||
Kind::Elytra => {}
|
||||
}
|
||||
self.failed.remove(&key);
|
||||
Download::Ready
|
||||
}
|
||||
None => Download::Failed,
|
||||
None => {
|
||||
self.failed.insert(key.clone(), std::time::Instant::now());
|
||||
Download::Failed
|
||||
}
|
||||
};
|
||||
self.states.insert(key, state);
|
||||
}
|
||||
@@ -899,34 +921,57 @@ impl Downloader {
|
||||
return Download::Failed;
|
||||
};
|
||||
let key = (kind, hash);
|
||||
if let Some(state) = self.states.get(&key) {
|
||||
return *state;
|
||||
if let Some(state) = self.states.get(&key).copied() {
|
||||
let due = self
|
||||
.failed
|
||||
.get(&key)
|
||||
.is_none_or(|at| at.elapsed() >= self.retry_after);
|
||||
if state != Download::Failed || !due {
|
||||
return state;
|
||||
}
|
||||
self.states.remove(&key);
|
||||
self.failed.remove(&key);
|
||||
}
|
||||
self.states.insert(key.clone(), Download::Pending);
|
||||
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
|
||||
let file = self
|
||||
.root
|
||||
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
|
||||
.join(&digest);
|
||||
let (url, send) = (url.to_owned(), self.send.clone());
|
||||
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
|
||||
std::thread::spawn(move || {
|
||||
let bytes = std::fs::read(&file).ok().or_else(|| {
|
||||
let body = http_get(&url)?;
|
||||
let _ = std::fs::create_dir_all(file.parent()?);
|
||||
let _ = std::fs::write(&file, &body);
|
||||
Some(body)
|
||||
});
|
||||
let image = bytes
|
||||
.and_then(|b| crate::hud::decode_bytes(&b).ok())
|
||||
.and_then(|(w, h, rgba)| match kind {
|
||||
Kind::Skin => process_legacy_skin(w, h, &rgba),
|
||||
Kind::Cape => process_cape(w, h, &rgba),
|
||||
Kind::Elytra => Some(rgba),
|
||||
});
|
||||
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
|
||||
let _ = send.send((key, image));
|
||||
});
|
||||
Download::Pending
|
||||
}
|
||||
/// One texture's pixels: the disk cache's body if it decodes, else a fetch
|
||||
/// (`SkinTextureDownloader.downloadSkin`). Only a body that decodes is
|
||||
/// written to the cache: an error page or a half image must not be what a
|
||||
/// later start reads instead of fetching again.
|
||||
fn load_texture(
|
||||
root: &std::path::Path,
|
||||
kind: Kind,
|
||||
digest: &str,
|
||||
url: &str,
|
||||
fetch: impl FnOnce(&str) -> Option<Vec<u8>>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let file = root
|
||||
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
|
||||
.join(digest);
|
||||
let decode = |bytes: &[u8]| -> Option<Vec<u8>> {
|
||||
let (w, h, rgba) = crate::hud::decode_bytes(bytes).ok()?;
|
||||
match kind {
|
||||
Kind::Skin => process_legacy_skin(w, h, &rgba),
|
||||
Kind::Cape => process_cape(w, h, &rgba),
|
||||
Kind::Elytra => Some(rgba),
|
||||
}
|
||||
};
|
||||
if let Some(image) = std::fs::read(&file).ok().and_then(|b| decode(&b)) {
|
||||
return Some(image);
|
||||
}
|
||||
let body = fetch(url)?;
|
||||
let image = decode(&body)?;
|
||||
let _ = std::fs::create_dir_all(file.parent()?);
|
||||
let _ = std::fs::write(&file, &body);
|
||||
Some(image)
|
||||
}
|
||||
/// Keeps a decoded skin for `again`, dropping the oldest past the bound.
|
||||
fn remember(&mut self, location: String, rgba: Vec<u8>) {
|
||||
if !self.images.contains_key(&location) {
|
||||
@@ -1516,4 +1561,156 @@ mod tests {
|
||||
"abc"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cape or elytra that cannot be fetched takes only itself out of the
|
||||
/// picture: the player's skin still shows. Only the skin texture's own
|
||||
/// failure is the default skin.
|
||||
#[test]
|
||||
fn a_failed_cape_does_not_hide_the_skin() {
|
||||
let textures = super::Textures {
|
||||
skin: Some("https://textures.minecraft.net/texture/skin1".into()),
|
||||
cape: Some("https://textures.minecraft.net/texture/cape1".into()),
|
||||
elytra: None,
|
||||
signed: true,
|
||||
slim: false,
|
||||
};
|
||||
let skin = super::lookup(&textures, true, |kind, _| match kind {
|
||||
super::Kind::Skin => super::Download::Ready,
|
||||
_ => super::Download::Failed,
|
||||
});
|
||||
assert_eq!(skin, Some(super::skin_location("skin1")));
|
||||
// A cape URL the hash cannot take is the same: the skin shows.
|
||||
let bad_url = super::Textures {
|
||||
cape: Some("no-scheme".into()),
|
||||
..textures.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
super::lookup(&bad_url, true, |_, _| super::Download::Ready),
|
||||
Some(super::skin_location("skin1"))
|
||||
);
|
||||
// The skin itself failing is still the default skin.
|
||||
assert_eq!(
|
||||
super::lookup(&textures, true, |_, _| super::Download::Failed),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// A 1x1 PNG of `rgba`.
|
||||
fn png(rgba: [u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&rgba).unwrap();
|
||||
writer.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A downloaded body reaches the disk cache only if it decodes; a broken
|
||||
/// cached file is fetched again and replaced. A failed fetch writes nothing.
|
||||
#[test]
|
||||
fn the_texture_cache_holds_only_what_decodes() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-test-skin-cache-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let body = png([7, 8, 9, 255]);
|
||||
let file = root.join("ab").join("abcdef");
|
||||
let load = |fetched: Option<&[u8]>| {
|
||||
let fetched = fetched.map(<[u8]>::to_vec);
|
||||
super::Downloader::load_texture(
|
||||
&root,
|
||||
super::Kind::Elytra,
|
||||
"abcdef",
|
||||
"https://example/texture/abc.png",
|
||||
|_| fetched.clone(),
|
||||
)
|
||||
};
|
||||
assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255]));
|
||||
assert_eq!(std::fs::read(&file).unwrap(), body);
|
||||
// From then on the cache answers; no fetch is needed.
|
||||
assert_eq!(load(None), Some(vec![7, 8, 9, 255]));
|
||||
// A broken file is fetched again and replaced, not kept.
|
||||
std::fs::write(&file, b"<html>not a png</html>").unwrap();
|
||||
assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255]));
|
||||
assert_eq!(std::fs::read(&file).unwrap(), body);
|
||||
// A body that does not decode is not cached at all.
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(load(Some(b"<html>not a png</html>")), None);
|
||||
assert!(!file.exists());
|
||||
}
|
||||
|
||||
fn wait_state(
|
||||
d: &mut super::Downloader,
|
||||
kind: super::Kind,
|
||||
url: &str,
|
||||
want: super::Download,
|
||||
) -> super::Download {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
let state = d.state(kind, url);
|
||||
if state == want || std::time::Instant::now() > deadline {
|
||||
return state;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// A texture download that failed is tried again after the wait, instead of
|
||||
/// pinning the player to the default skin for the rest of the session.
|
||||
#[test]
|
||||
fn a_failed_download_is_retried_after_the_wait() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let body = png([1, 2, 3, 4]);
|
||||
let served = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let count = served.clone();
|
||||
std::thread::spawn(move || {
|
||||
for (i, stream) in listener.incoming().take(2).enumerate() {
|
||||
let mut stream = stream.unwrap();
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
reader.read_line(&mut line).unwrap();
|
||||
while reader.read_line(&mut String::new()).unwrap() > 2 {}
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
if i == 0 {
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(head.as_bytes()).unwrap();
|
||||
stream.write_all(&body).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-test-skin-retry-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut d = super::Downloader::new(root);
|
||||
let url = format!("{base}/texture/retry.png");
|
||||
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending);
|
||||
assert_eq!(
|
||||
wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Failed),
|
||||
super::Download::Failed
|
||||
);
|
||||
// Within the wait the failure stands: no request goes out.
|
||||
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Failed);
|
||||
assert_eq!(served.load(Ordering::SeqCst), 1);
|
||||
// After it, the download is started again and takes the good answer.
|
||||
d.retry_after = std::time::Duration::ZERO;
|
||||
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending);
|
||||
assert_eq!(
|
||||
wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Ready),
|
||||
super::Download::Ready
|
||||
);
|
||||
assert_eq!(served.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,10 @@ impl SoundEntity {
|
||||
pub enum Ticking {
|
||||
/// Not tickable.
|
||||
No,
|
||||
/// A compiled native owner updates this instance through `instance_mut`.
|
||||
/// The ordinary engine applies gain, pitch and position each unpaused tick;
|
||||
/// no vanilla entity behavior overwrites them. Stop and release when done.
|
||||
Native,
|
||||
/// `EntityBoundSoundInstance`.
|
||||
EntityBound,
|
||||
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
|
||||
@@ -611,7 +615,7 @@ impl SoundInstance {
|
||||
/// `TickableSoundInstance.tick`.
|
||||
pub fn tick(&mut self, ctx: &TickContext) {
|
||||
match &mut self.ticking {
|
||||
Ticking::No | Ticking::MusicLayer => {}
|
||||
Ticking::No | Ticking::Native | Ticking::MusicLayer => {}
|
||||
Ticking::EntityBound => {
|
||||
let entity = self
|
||||
.bound_entity
|
||||
@@ -1552,6 +1556,8 @@ pub mod test_backend {
|
||||
next: ChannelId,
|
||||
pub stopped: HashMap<ChannelId, bool>,
|
||||
pub volume: HashMap<ChannelId, f32>,
|
||||
pub pitch: HashMap<ChannelId, f32>,
|
||||
pub position: HashMap<ChannelId, [f64; 3]>,
|
||||
}
|
||||
impl Backend for Null {
|
||||
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
|
||||
@@ -1562,13 +1568,17 @@ pub mod test_backend {
|
||||
fn destroy(&mut self, c: ChannelId) {
|
||||
self.stopped.remove(&c);
|
||||
}
|
||||
fn set_pitch(&mut self, _: ChannelId, _: f32) {}
|
||||
fn set_pitch(&mut self, c: ChannelId, value: f32) {
|
||||
self.pitch.insert(c, value);
|
||||
}
|
||||
fn set_volume(&mut self, c: ChannelId, v: f32) {
|
||||
self.volume.insert(c, v);
|
||||
}
|
||||
fn set_attenuation(&mut self, _: ChannelId, _: Option<f32>) {}
|
||||
fn set_looping(&mut self, _: ChannelId, _: bool) {}
|
||||
fn set_position(&mut self, _: ChannelId, _: [f64; 3]) {}
|
||||
fn set_position(&mut self, c: ChannelId, value: [f64; 3]) {
|
||||
self.position.insert(c, value);
|
||||
}
|
||||
fn set_relative(&mut self, _: ChannelId, _: bool) {}
|
||||
fn start(&mut self, _: ChannelId, _: &Sound, _: bool) {}
|
||||
fn stop(&mut self, c: ChannelId) {
|
||||
@@ -1586,6 +1596,76 @@ pub mod test_backend {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_owned_loop_applies_updates_and_stops_through_the_ordinary_engine() {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"example",
|
||||
br#"{"loop":{"sounds":["machine/loop"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
let mut backend = test_backend::Null::default();
|
||||
let mut instance = SoundInstance::new(
|
||||
"example:loop",
|
||||
4,
|
||||
0.1,
|
||||
0.5,
|
||||
LegacyRandom::new(1),
|
||||
true,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
true,
|
||||
);
|
||||
instance.ticking = Ticking::Native;
|
||||
let (id, result) = engine.play_new(instance, true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.volume[&channel], 0.1);
|
||||
let owned = engine.instance_mut(id).unwrap();
|
||||
owned.volume = 0.4;
|
||||
owned.pitch = 1.25;
|
||||
owned.position = [2.0, 3.0, 4.0];
|
||||
engine.tick(true, &TickContext::default(), &mut backend);
|
||||
assert_eq!(
|
||||
backend.volume[&channel], 0.1,
|
||||
"paused world sounds retain channel values"
|
||||
);
|
||||
assert_eq!(backend.pitch[&channel], 0.5);
|
||||
assert_eq!(backend.position[&channel], [0.0; 3]);
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
assert_eq!(backend.volume[&channel], 0.4);
|
||||
assert_eq!(backend.pitch[&channel], 1.25);
|
||||
assert_eq!(backend.position[&channel], [2.0, 3.0, 4.0]);
|
||||
let owned = engine.instance(id).unwrap();
|
||||
assert_eq!((owned.pitch, owned.position), (1.25, [2.0, 3.0, 4.0]));
|
||||
let replacement = owned.clone();
|
||||
assert!(engine.is_active(id));
|
||||
engine.stop(id, &mut backend);
|
||||
assert_eq!(backend.stopped.get(&channel), Some(&true));
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
engine.release(id);
|
||||
for _ in 0..20 {
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
}
|
||||
assert!(engine.instance(id).is_none());
|
||||
assert!(!engine.is_active(id));
|
||||
assert!(!backend.stopped.contains_key(&channel));
|
||||
|
||||
let (id, result) = engine.play_new(replacement, true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
engine.stop_all(&mut backend);
|
||||
assert!(!engine.is_active(id));
|
||||
assert!(backend.stopped.is_empty());
|
||||
assert!(engine.instance(id).is_some(), "the owner still retains it");
|
||||
engine.release(id);
|
||||
assert!(engine.instance(id).is_none());
|
||||
assert_eq!(engine.census()["playing"], 0);
|
||||
assert_eq!(engine.census()["ticking"], 0);
|
||||
assert_eq!(engine.census()["handles"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_ghast_riding_sound_follows_the_ghast_speed() {
|
||||
let ghast = RiddenSound {
|
||||
|
||||
@@ -351,6 +351,14 @@ impl SplitProfiles {
|
||||
if matches!(target, Target::Forget) {
|
||||
text = text.with_color(0xFF5555);
|
||||
}
|
||||
let action = match target {
|
||||
Target::Keep | Target::Cancel => Some(crate::widgets::Action::Cancel),
|
||||
Target::Back => Some(crate::widgets::Action::Back),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
crate::widgets::declare_action(action, [*x, *y, *bw, *bh], *enabled);
|
||||
}
|
||||
ui::button(batch, atlas, *x, *y, *bw, *bh, &text, *enabled, hover);
|
||||
if remove {
|
||||
crate::widgets::record(label, [*x, *y, *bw, *bh], *enabled);
|
||||
|
||||
@@ -691,6 +691,16 @@ impl SplitUi {
|
||||
Target::Three => Some(layout_glyph(true, d.three)),
|
||||
_ => None,
|
||||
});
|
||||
let action = match target {
|
||||
Target::Apply => Some(crate::widgets::Action::Apply),
|
||||
Target::Done => Some(crate::widgets::Action::Done),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
// Off during a hold: every button goes to the held player.
|
||||
let enabled = *active && self.held.is_none();
|
||||
crate::widgets::declare_action(action, [x, y, bw, bh], enabled);
|
||||
}
|
||||
// The selected player's name in yellow: picked, not just under the pointer.
|
||||
let text = if glyph.is_some() {
|
||||
Text::literal("")
|
||||
@@ -1499,6 +1509,30 @@ mod tests {
|
||||
assert!(ui.give_up_pad);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_is_declared_off_while_a_player_is_held() {
|
||||
use crate::widgets::{Action, end_frame, last_actions, set_action_screen};
|
||||
let atlas =
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
let mut ui = screen(2);
|
||||
ui.size = SIZE;
|
||||
let done = |ui: &SplitUi, frame: u64| {
|
||||
let mut batch = HudBatch::new([427, 300]);
|
||||
ui.draw(&mut batch, &atlas, (0.0, 0.0), false);
|
||||
set_action_screen(9);
|
||||
end_frame(frame);
|
||||
last_actions(9)
|
||||
.into_iter()
|
||||
.find(|a| a.action == Action::Done)
|
||||
.map(|a| a.enabled)
|
||||
};
|
||||
assert_eq!(done(&ui, 9001), Some(true));
|
||||
assert!(ui.pad_pick_up(centre(&ui, "P2")));
|
||||
assert_eq!(done(&ui, 9002), Some(false));
|
||||
assert!(ui.pad_button(PadButton::East));
|
||||
assert_eq!(done(&ui, 9003), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_puts_a_held_player_back() {
|
||||
let mut ui = screen(2);
|
||||
|
||||
@@ -543,6 +543,38 @@ pub fn button(
|
||||
crate::widgets::Narratable::button(label.plain(), rect, active)
|
||||
});
|
||||
}
|
||||
/// A [`button`] with a job on its screen: its controller button and glyph come from `action`, so
|
||||
/// a new screen gets them by drawing this for its Done, Back, Cancel or Apply.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn action_button(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
action: crate::widgets::Action,
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
h: f32,
|
||||
label: &Text,
|
||||
active: bool,
|
||||
hover: bool,
|
||||
) {
|
||||
crate::widgets::declare_action(action, [x, y, w, h], active);
|
||||
let room = if active {
|
||||
crate::widgets::action_glyph_room()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
button_with_room(
|
||||
batch,
|
||||
atlas,
|
||||
[x, y, w, h],
|
||||
label,
|
||||
active,
|
||||
hover,
|
||||
room,
|
||||
|rect| crate::widgets::Narratable::button(label.plain(), rect, active),
|
||||
);
|
||||
}
|
||||
/// A [`button`] that vanilla narrates as another widget (a `CycleButton`, a
|
||||
/// `LockIconButton`): `narration` builds what it records from its rectangle,
|
||||
/// and only while the narrator records.
|
||||
@@ -558,6 +590,29 @@ pub fn button_as(
|
||||
active: bool,
|
||||
hover: bool,
|
||||
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
|
||||
) {
|
||||
button_with_room(
|
||||
batch,
|
||||
atlas,
|
||||
[x, y, w, h],
|
||||
label,
|
||||
active,
|
||||
hover,
|
||||
0.0,
|
||||
narration,
|
||||
);
|
||||
}
|
||||
/// A button whose label keeps `room` free at its left.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn button_with_room(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
[x, y, w, h]: [f32; 4],
|
||||
label: &Text,
|
||||
active: bool,
|
||||
hover: bool,
|
||||
room: f32,
|
||||
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
|
||||
) {
|
||||
crate::widgets::record(&label.plain(), [x, y, w, h], active);
|
||||
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
|
||||
@@ -575,15 +630,18 @@ pub fn button_as(
|
||||
} else {
|
||||
rgba(160, 160, 160, 255)
|
||||
};
|
||||
scrolling_text(
|
||||
// Centred on the button, as the title's are, and only moved out from under a glyph.
|
||||
scrolling_text_around_at(
|
||||
batch,
|
||||
atlas,
|
||||
label,
|
||||
x + 2.0,
|
||||
x + (w / 2.0).floor(),
|
||||
x + 2.0 + room,
|
||||
x + w - 2.0,
|
||||
y,
|
||||
y + h,
|
||||
y + ((h - 8.0) / 2.0).floor(),
|
||||
crate::hud_overlays::now_ms(),
|
||||
color,
|
||||
);
|
||||
}
|
||||
@@ -1740,6 +1798,31 @@ mod tests {
|
||||
/// The label of a widget it does not fit is clipped to the widget's box
|
||||
/// instead of being drawn past it (the bug: `so_so`'s 590 px Create World
|
||||
/// label ran over the button beside it).
|
||||
#[test]
|
||||
fn a_glyph_leaves_a_label_that_fits_centred() {
|
||||
let atlas = atlas();
|
||||
crate::widgets::set_action_glyphs(true);
|
||||
let label = Text::literal("Done");
|
||||
let mut plain = HudBatch::new([640, 480]);
|
||||
button(
|
||||
&mut plain, &atlas, 10.0, 20.0, 150.0, 20.0, &label, true, false,
|
||||
);
|
||||
let mut declared = HudBatch::new([640, 480]);
|
||||
action_button(
|
||||
&mut declared,
|
||||
&atlas,
|
||||
crate::widgets::Action::Done,
|
||||
10.0,
|
||||
20.0,
|
||||
150.0,
|
||||
20.0,
|
||||
&label,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert_eq!(quads(&plain), quads(&declared));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_wider_than_its_widget_stays_inside_it() {
|
||||
let atlas = atlas();
|
||||
|
||||
@@ -96,6 +96,12 @@ thread_local! {
|
||||
/// on-screen keyboard's keys), labelled or not: where a controller can move focus. Drawn since
|
||||
/// `end_frame`, the last frame's, and how many frames rotated.
|
||||
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
|
||||
/// The buttons drawn since the last `end_frame` that declared their job, and the last frame's.
|
||||
static ACTIONS: RefCell<(Vec<DeclaredAction>, Vec<DeclaredAction>)> = const { RefCell::new((Vec::new(), Vec::new())) };
|
||||
/// The pad drives, so declared buttons show their glyph (labels leave it room).
|
||||
static ACTION_GLYPHS: Cell<bool> = const { Cell::new(false) };
|
||||
/// The screen the buttons in `ACTIONS` were declared on, the same two frames.
|
||||
static ACTION_SCREENS: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
|
||||
@@ -104,17 +110,94 @@ thread_local! {
|
||||
pub struct Saved(
|
||||
(Vec<Widget>, Vec<Widget>, u64),
|
||||
(Vec<Target>, Vec<Target>, u64),
|
||||
(Vec<DeclaredAction>, Vec<DeclaredAction>),
|
||||
bool,
|
||||
(u64, u64),
|
||||
);
|
||||
|
||||
/// This thread's buttons and targets, leaving none.
|
||||
pub fn take() -> Saved {
|
||||
Saved(WIDGETS.take(), TARGETS.take())
|
||||
Saved(
|
||||
WIDGETS.take(),
|
||||
TARGETS.take(),
|
||||
ACTIONS.take(),
|
||||
ACTION_GLYPHS.take(),
|
||||
ACTION_SCREENS.take(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bring back what [`take`] kept.
|
||||
pub fn put(saved: Saved) {
|
||||
WIDGETS.set(saved.0);
|
||||
TARGETS.set(saved.1);
|
||||
ACTIONS.set(saved.2);
|
||||
ACTION_GLYPHS.set(saved.3);
|
||||
ACTION_SCREENS.set(saved.4);
|
||||
}
|
||||
|
||||
/// A button's job on its screen. A screen declares it ([`crate::ui::action_button`]) and the
|
||||
/// controller's button, its glyph and its place on every pad follow (docs/controllers.md,
|
||||
/// "Adding a screen").
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Leaves the screen, keeping what was set.
|
||||
Done,
|
||||
/// Goes back one screen.
|
||||
Back,
|
||||
/// Leaves the screen, dropping what was set.
|
||||
Cancel,
|
||||
/// Applies what was set and stays or leaves as the screen does.
|
||||
Apply,
|
||||
/// Opens the options.
|
||||
Options,
|
||||
/// Leaves the game or the world: the pad moves the focus here, and A confirms.
|
||||
Exit,
|
||||
}
|
||||
|
||||
/// A button that declared its job, as last drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DeclaredAction {
|
||||
pub action: Action,
|
||||
pub rect: [f32; 4],
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// `rect` is the button doing `action` on this screen; see [`crate::ui::action_button`], for a
|
||||
/// screen that draws its buttons itself.
|
||||
pub fn declare_action(action: Action, rect: [f32; 4], enabled: bool) {
|
||||
ACTIONS.with_borrow_mut(|(current, _)| {
|
||||
if current.len() < 16 {
|
||||
current.push(DeclaredAction {
|
||||
action,
|
||||
rect,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The buttons of the last drawn frame that declared their job, if it drew `screen`: until the
|
||||
/// screen just opened is drawn, the one it replaced can't be pressed.
|
||||
pub fn last_actions(screen: u64) -> Vec<DeclaredAction> {
|
||||
if ACTION_SCREENS.get().1 != screen {
|
||||
return Vec::new();
|
||||
}
|
||||
ACTIONS.with_borrow(|(_, last)| last.clone())
|
||||
}
|
||||
|
||||
/// The frame being drawn shows `screen` (the controller's own key for it).
|
||||
pub fn set_action_screen(screen: u64) {
|
||||
ACTION_SCREENS.set((screen, ACTION_SCREENS.get().1));
|
||||
}
|
||||
|
||||
/// Whether declared buttons are showing their glyph: only while the pad drives.
|
||||
pub fn set_action_glyphs(on: bool) {
|
||||
ACTION_GLYPHS.set(on);
|
||||
}
|
||||
|
||||
/// How much of a declared button's left the glyph takes from its label.
|
||||
pub fn action_glyph_room() -> f32 {
|
||||
if ACTION_GLYPHS.get() { 14.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// An interactive rectangle (GUI pixels) a controller can focus and click.
|
||||
@@ -225,6 +308,10 @@ pub fn end_frame(frames: u64) {
|
||||
*last = std::mem::take(current);
|
||||
*generation += 1;
|
||||
});
|
||||
ACTIONS.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::take(current);
|
||||
});
|
||||
ACTION_SCREENS.set((ACTION_SCREENS.get().0, ACTION_SCREENS.get().0));
|
||||
NARRATION.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::replace(current, Narration::EMPTY);
|
||||
});
|
||||
@@ -700,8 +787,10 @@ pub fn narrating() -> bool {
|
||||
|
||||
/// The screen being drawn: what it narrates and which one it is. The
|
||||
/// narratables recorded before it in the frame are dropped: the last screen
|
||||
/// drawn (an overlay over the screen under it) is the one narrated.
|
||||
/// drawn (an overlay over the screen under it) is the one narrated. So are the
|
||||
/// jobs declared before it: a popup's buried buttons can't be pressed.
|
||||
pub fn begin_screen(info: impl FnOnce() -> ScreenInfo) {
|
||||
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
|
||||
if NARRATING.get() {
|
||||
NARRATION.with_borrow_mut(|(current, _)| {
|
||||
current.screen = Some(info());
|
||||
@@ -1153,6 +1242,29 @@ mod tests {
|
||||
assert_eq!(slug("Économie"), "économie");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_buttons_are_only_those_of_the_screen_drawn() {
|
||||
let done = |x| declare_action(Action::Done, [x, 0.0, 5.0, 5.0], true);
|
||||
done(0.0);
|
||||
set_action_screen(7);
|
||||
end_frame(200);
|
||||
assert_eq!(last_actions(7).len(), 1);
|
||||
// Another screen opened and hasn't been drawn yet.
|
||||
assert!(last_actions(8).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_popup_drops_the_buttons_declared_under_it() {
|
||||
declare_action(Action::Done, [0.0, 0.0, 5.0, 5.0], true);
|
||||
begin_screen(|| ScreenInfo::new("popup", ""));
|
||||
declare_action(Action::Cancel, [9.0, 0.0, 5.0, 5.0], true);
|
||||
set_action_screen(7);
|
||||
end_frame(300);
|
||||
let actions = last_actions(7);
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert_eq!(actions[0].action, Action::Cancel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_lists_buttons_only() {
|
||||
end_frame(100);
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"LOD Distance": "LOD Distance",
|
||||
"Far Detail": "Far Detail",
|
||||
"Cave Culling": "Cave Culling",
|
||||
"GPU Culling": "GPU Culling",
|
||||
"Entity Culling": "Entity Culling",
|
||||
"Chunk Updates": "Chunk Updates",
|
||||
"Upload Budget": "Upload Budget",
|
||||
@@ -158,6 +159,9 @@
|
||||
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
|
||||
"Turn HDR on to use it.": "Turn HDR on to use it.",
|
||||
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
|
||||
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
|
||||
"Not on DirectX 12 yet: try the Vulkan graphics API.": "Not on DirectX 12 yet: try the Vulkan graphics API.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s per frame",
|
||||
"%s MiB/frame": "%s MiB/frame",
|
||||
@@ -180,6 +184,7 @@
|
||||
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
|
||||
"Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.": "Culls nearby terrain on the GPU: less CPU at high render distance, more GPU.",
|
||||
"Hides entities behind opaque blocks.": "Hides entities behind opaque blocks.",
|
||||
"No entity occlusion culling.": "No entity occlusion culling.",
|
||||
"CPU occlusion test runs this frame.": "CPU occlusion test runs this frame.",
|
||||
@@ -338,6 +343,7 @@
|
||||
"horizon.friends.stopped": "Stopped sharing the invite code.",
|
||||
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
|
||||
"horizon.friends.wrong_code": "Someone tried a wrong code.",
|
||||
"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.",
|
||||
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
"horizon.friends.share_failed": "Couldn't share the world: %s",
|
||||
"horizon.friends.finding_route": "Finding how friends can reach you…",
|
||||
|
||||
@@ -1130,6 +1130,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
main_hand: s.hand.as_deref().map(Into::into),
|
||||
off_hand: None,
|
||||
head: None,
|
||||
chest: None,
|
||||
legs: None,
|
||||
feet: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
|
||||
@@ -605,6 +605,10 @@ pub trait World {
|
||||
fn light(&self, pos: [i32; 3]) -> i32;
|
||||
fn smokey(&self, pos: [i32; 3]) -> bool;
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool;
|
||||
/// The block entity's `saveWithFullMetadata` at `pos`, `None` without one.
|
||||
fn block_entity_nbt(&self, _pos: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// An item stack as predicates see it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -853,6 +857,16 @@ pub fn block(v: &Value, pos: [i32; 3], w: &dyn World) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `matchesBlockEntity`: the block entity's data holds the pattern
|
||||
// (`NbtPredicate.matches`, `NbtUtils.compareNbt`); no block entity fails.
|
||||
if let Some(nbt) = v["nbt"].as_str() {
|
||||
let Ok((pattern, _)) = horizon_world::nbt::parse_snbt(nbt) else {
|
||||
return false;
|
||||
};
|
||||
if !crate::nbt_path::compare_nbt(&pattern, w.block_entity_nbt(pos).as_ref()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `LocationPredicate`.
|
||||
@@ -1810,6 +1824,67 @@ mod tests {
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// `BlockPredicate.matchesBlockEntity`: an `nbt` test needs a block
|
||||
/// entity whose `saveWithFullMetadata` contains the pattern
|
||||
/// (`NbtPredicate.matches`), so it never matches a plain block.
|
||||
#[test]
|
||||
fn a_block_predicates_nbt_needs_the_block_entitys_data() {
|
||||
let w = Flat;
|
||||
let stone = serde_json::json!({"blocks": "minecraft:stone", "nbt": "{}"});
|
||||
assert!(!block(&stone, [0, 0, 0], &w), "stone has no block entity");
|
||||
assert!(block(
|
||||
&serde_json::json!({"blocks": "minecraft:stone"}),
|
||||
[0, 0, 0],
|
||||
&w
|
||||
));
|
||||
/// An unpowered beacon: its saved data has `Levels:0`.
|
||||
struct Beacon;
|
||||
impl World for Beacon {
|
||||
fn dimension(&self) -> &str {
|
||||
Flat.dimension()
|
||||
}
|
||||
fn weather(&self) -> (bool, bool) {
|
||||
Flat.weather()
|
||||
}
|
||||
fn biome(&self, p: [i32; 3]) -> String {
|
||||
Flat.biome(p)
|
||||
}
|
||||
fn structures(&self, p: [i32; 3]) -> Vec<String> {
|
||||
Flat.structures(p)
|
||||
}
|
||||
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
|
||||
("minecraft:beacon".into(), BTreeMap::new())
|
||||
}
|
||||
fn fluid(&self, _: [i32; 3]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn can_see_sky(&self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
}
|
||||
fn light(&self, _: [i32; 3]) -> i32 {
|
||||
15
|
||||
}
|
||||
fn smokey(&self, _: [i32; 3]) -> bool {
|
||||
false
|
||||
}
|
||||
fn in_tag(&self, _: &str, _: &str, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn block_entity_nbt(&self, _: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
horizon_world::nbt::parse_snbt(r#"{id:"minecraft:beacon",x:0,y:0,z:0,Levels:0}"#)
|
||||
.ok()
|
||||
.map(|(t, _)| t)
|
||||
}
|
||||
}
|
||||
let beacon = |nbt: &str| serde_json::json!({"blocks": "minecraft:beacon", "nbt": nbt});
|
||||
assert!(block(&beacon("{Levels:0}"), [0, 0, 0], &Beacon));
|
||||
assert!(block(&beacon("{}"), [0, 0, 0], &Beacon));
|
||||
assert!(!block(&beacon("{Levels:4}"), [0, 0, 0], &Beacon));
|
||||
assert!(
|
||||
!block(&beacon("{Levels:0b}"), [0, 0, 0], &Beacon),
|
||||
"an int, not a byte"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn weather_conditions_leave_absent_fields_unconstrained_and_compose() {
|
||||
let ctx = Entities {
|
||||
|
||||
@@ -164,6 +164,10 @@ impl Default for Ambience {
|
||||
}
|
||||
}
|
||||
impl Ambience {
|
||||
/// Starts its random over from `seed`.
|
||||
pub fn reseed(&mut self, seed: i64) {
|
||||
self.random = LegacyRandom::new(seed);
|
||||
}
|
||||
pub fn new(seed: i64) -> Self {
|
||||
Self {
|
||||
random: LegacyRandom::new(seed),
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
//! milliseconds turn into game ticks, partial ticks and real-time deltas, and
|
||||
//! the `/tick` freeze, step and sprint state.
|
||||
|
||||
/// `HORIZON_STILL=1`, read once (`FixedStep::still`).
|
||||
fn still_requested() -> bool {
|
||||
static STILL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*STILL.get_or_init(|| {
|
||||
let on = std::env::var("HORIZON_STILL").is_ok_and(|v| v == "1");
|
||||
if on {
|
||||
log::warn!("HORIZON_STILL=1: worlds start frozen (/tick unfreeze), frames follow the world's ticks");
|
||||
}
|
||||
on
|
||||
})
|
||||
}
|
||||
|
||||
/// `DeltaTracker.Timer`: float arithmetic on integer milliseconds, exactly as
|
||||
/// the client does it.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -311,11 +323,23 @@ pub struct FixedStep {
|
||||
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
|
||||
step_changes: u32,
|
||||
step_requested: i32,
|
||||
/// `HORIZON_STILL=1`, a dev switch for screenshots that come out the same every run: the
|
||||
/// world starts frozen, and while it is, the camera's partial tick is held at 1 as the
|
||||
/// level's is. With the frame's other time (`Game::visual_ticks`) following the world's
|
||||
/// ticks, a frozen scene draws the same frame every time, and `/tick step` moves it on by
|
||||
/// whole ticks.
|
||||
still: bool,
|
||||
}
|
||||
|
||||
impl Default for FixedStep {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
Self::new(still_requested())
|
||||
}
|
||||
}
|
||||
|
||||
impl FixedStep {
|
||||
fn new(still: bool) -> Self {
|
||||
let mut clock = Self {
|
||||
timer: Timer::new(20.0, 0),
|
||||
manager: ServerTickRateManager::default(),
|
||||
now: std::time::Duration::ZERO,
|
||||
@@ -328,11 +352,16 @@ impl Default for FixedStep {
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
}
|
||||
still,
|
||||
};
|
||||
// Frozen before any tick: how many run while the world loads differs between runs.
|
||||
clock.manager.base.set_frozen(still);
|
||||
clock
|
||||
}
|
||||
/// `HORIZON_STILL` (`still`).
|
||||
pub fn still(&self) -> bool {
|
||||
self.still
|
||||
}
|
||||
}
|
||||
|
||||
impl FixedStep {
|
||||
/// The clock of a client connected to a server: the `ClientLevel`'s
|
||||
/// `TickRateManager`, set by the server's ticking packets.
|
||||
pub fn client() -> Self {
|
||||
@@ -386,8 +415,12 @@ impl FixedStep {
|
||||
}
|
||||
/// The camera entity's partial tick (`Camera.getCameraEntityPartialTicks`
|
||||
/// for a player, who is never frozen): `getGameTimeDeltaPartialTick(true)`,
|
||||
/// so the view and the hand stay smooth in a frozen level.
|
||||
/// so the view and the hand stay smooth in a frozen level. Under `still`, held at 1 there
|
||||
/// like the level's, so a frame doesn't depend on when it's drawn.
|
||||
pub fn camera_fraction(&self) -> f64 {
|
||||
if self.still && !self.runs_normally() {
|
||||
return 1.0;
|
||||
}
|
||||
f64::from(self.timer.game_time_delta_partial_tick(true))
|
||||
}
|
||||
/// After a frame: whether the game is paused by a screen (the residual
|
||||
@@ -555,6 +588,23 @@ mod tests {
|
||||
assert_eq!(clock.camera_fraction(), 0.5, "the player does not");
|
||||
}
|
||||
#[test]
|
||||
fn a_still_clock_starts_frozen_and_holds_the_camera_too() {
|
||||
let mut clock = FixedStep::new(true);
|
||||
clock.world_runs();
|
||||
assert!(!clock.runs_normally(), "frozen before the first tick");
|
||||
clock.advance(std::time::Duration::from_millis(25));
|
||||
clock.end_frame(false);
|
||||
assert_eq!(clock.fraction(), 1.0);
|
||||
assert_eq!(clock.camera_fraction(), 1.0);
|
||||
// `/tick unfreeze`: the world runs as without the switch.
|
||||
clock.set_frozen(false);
|
||||
assert!(clock.world_runs());
|
||||
assert!(
|
||||
FixedStep::new(false).world_runs(),
|
||||
"without the switch, unfrozen"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn only_a_started_or_stopped_step_is_broadcast() {
|
||||
let mut clock = FixedStep::default();
|
||||
assert!(!clock.start_step(5), "not frozen");
|
||||
|
||||
@@ -67,6 +67,11 @@ impl Confirm {
|
||||
) {
|
||||
self.draw_active(batch, atlas, cursor, labels, true);
|
||||
}
|
||||
/// The second button answers no, as Escape does (`widgets::Action`).
|
||||
pub fn declare_no(&self) {
|
||||
let (x, y) = self.buttons[1];
|
||||
crate::widgets::declare_action(crate::widgets::Action::Cancel, [x, y, 150.0, 20.0], true);
|
||||
}
|
||||
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
|
||||
pub fn draw_active(
|
||||
&self,
|
||||
@@ -183,6 +188,16 @@ impl Choice {
|
||||
.iter()
|
||||
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
|
||||
}
|
||||
/// Button `i` answers no, as Escape does (`widgets::Action`).
|
||||
pub fn declare_no(&self, i: usize) {
|
||||
if let Some(&(x, y)) = self.buttons.get(i) {
|
||||
crate::widgets::declare_action(
|
||||
crate::widgets::Action::Cancel,
|
||||
[x, y, self.width, 20.0],
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The lines and the buttons, over whatever is behind.
|
||||
pub fn draw(
|
||||
&self,
|
||||
|
||||
@@ -642,6 +642,19 @@ impl Dialog {
|
||||
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
|
||||
}
|
||||
}
|
||||
/// Which of [`Self::buttons`] does what Escape does, if Escape does anything.
|
||||
pub fn escape_button(&self) -> Option<usize> {
|
||||
if !self.can_close_with_escape {
|
||||
return None;
|
||||
}
|
||||
let last = self.buttons().len().checked_sub(1);
|
||||
match &self.kind {
|
||||
Kind::Notice(_) | Kind::Confirmation { .. } => last,
|
||||
Kind::MultiAction { exit, .. }
|
||||
| Kind::ServerLinks { exit, .. }
|
||||
| Kind::DialogList { exit, .. } => exit.as_ref().and(last),
|
||||
}
|
||||
}
|
||||
/// The buttons it draws, in order: the list, then the footer's.
|
||||
pub fn buttons(&self) -> Vec<&Button> {
|
||||
match &self.kind {
|
||||
@@ -882,6 +895,22 @@ mod tests {
|
||||
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_button_that_does_what_escape_does() {
|
||||
let notice = |escape: i8| {
|
||||
Dialog::parse(&Tag::compound([
|
||||
("type", s("notice")),
|
||||
("title", s("Hi")),
|
||||
("can_close_with_escape", Tag::Byte(escape)),
|
||||
]))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(notice(1).escape_button(), Some(0));
|
||||
assert_eq!(notice(0).escape_button(), None);
|
||||
// Multi-action with no exit button: Escape has no button to be.
|
||||
assert_eq!(Dialog::parse(®ister()).unwrap().escape_button(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notice_without_an_action_has_ok() {
|
||||
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).unwrap();
|
||||
|
||||
@@ -1591,6 +1591,8 @@ pub struct ItemFrame {
|
||||
pub fixed: bool,
|
||||
pub invisible: bool,
|
||||
pub check_interval: u32,
|
||||
/// `Entity.getCustomName` (`CustomName`), copied onto the dropped frame.
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
}
|
||||
impl ItemFrame {
|
||||
pub fn step(&self) -> [i32; 3] {
|
||||
@@ -1620,6 +1622,16 @@ impl ItemFrame {
|
||||
std::array::from_fn(|i| c[i] + size[i] / 2.0),
|
||||
)
|
||||
}
|
||||
/// `ItemFrame.getFrameItemStackWithData`: `getFrameItemStack` (a glow
|
||||
/// frame's own item), then `DataComponents.CUSTOM_NAME` from
|
||||
/// `getCustomName`.
|
||||
pub fn item_stack_with_data(&self) -> ItemStack {
|
||||
let mut item_stack = ItemStack::new(self.kind(), 1);
|
||||
if let Some(name) = &self.custom_name {
|
||||
item_stack.data_mut().custom_name = Some(name.clone());
|
||||
}
|
||||
item_stack
|
||||
}
|
||||
}
|
||||
/// An armor stand (`ArmorStand`).
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -1952,7 +1952,8 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
read_variant(&mut m, tag);
|
||||
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
|
||||
// Entity.load: `input.getIntOr("Air", getMaxAirSupply())`, 4800 for a dolphin.
|
||||
m.air = number(tag.get("Air")).map_or(m.max_air_supply(), |air| air as i32);
|
||||
if m.species() == crate::mobs::Species::Allay {
|
||||
if let Some(Tag::List(inv)) = tag.get("Inventory") {
|
||||
for stack in inv.iter().filter_map(item_from_nbt) {
|
||||
@@ -3186,6 +3187,12 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
// `Entity.saveWithoutId`: `CustomName` before the frame's own fields.
|
||||
if let Some(name) = &f.custom_name {
|
||||
put(&mut tag, "CustomName", name.clone());
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"CustomName".into());
|
||||
}
|
||||
put(&mut tag, "Facing", Tag::Byte(f.facing as i8));
|
||||
put(&mut tag, "block_pos", Tag::IntArray(f.pos.to_vec()));
|
||||
put(&mut tag, "ItemRotation", Tag::Byte(f.rotation as i8));
|
||||
@@ -3217,6 +3224,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
|
||||
fixed: flag(tag, "Fixed"),
|
||||
invisible: flag(tag, "Invisible"),
|
||||
check_interval: 0,
|
||||
custom_name: tag.get("CustomName").cloned(),
|
||||
})
|
||||
}
|
||||
/// ArmorStand.addAdditionalSaveData with LivingEntity's `equipment`.
|
||||
@@ -6290,6 +6298,41 @@ mod fireball_nbt_tests {
|
||||
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_frame_drops_with_its_custom_name() {
|
||||
// `ItemFrame.getFrameItemStackWithData`: the frame item, then
|
||||
// `DataComponents.CUSTOM_NAME` from `getCustomName`. Glow frames
|
||||
// share it through `GlowItemFrame.getFrameItemStack`.
|
||||
for id in ["minecraft:item_frame", "minecraft:glow_item_frame"] {
|
||||
let tag = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Facing", Tag::Byte(2)),
|
||||
("CustomName", string("Gallery")),
|
||||
]);
|
||||
let frame = item_frame_from_nbt(&tag, 1).unwrap();
|
||||
let stack = frame.item_stack_with_data();
|
||||
assert_eq!(&*stack.item, id);
|
||||
assert_eq!(stack.count, 1);
|
||||
assert_eq!(stack.custom_name().as_deref(), Some("Gallery"));
|
||||
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(saved.get("CustomName")).as_deref(), Some("Gallery"));
|
||||
}
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:item_frame")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Facing", Tag::Byte(3)),
|
||||
]);
|
||||
let frame = item_frame_from_nbt(&bare, 2).unwrap();
|
||||
assert!(frame.custom_name.is_none());
|
||||
assert!(frame.item_stack_with_data().custom_name().is_none());
|
||||
assert!(
|
||||
item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&bare))
|
||||
.get("CustomName")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projectile_shot_flag_round_trips_independently_of_age() {
|
||||
for id in [
|
||||
|
||||
@@ -244,6 +244,13 @@ pub fn draw(
|
||||
}
|
||||
for b in buttons {
|
||||
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
|
||||
if matches!(b.action, Action::Done) {
|
||||
crate::widgets::declare_action(
|
||||
crate::widgets::Action::Done,
|
||||
[b.x, b.y, b.w, 20.0],
|
||||
b.active,
|
||||
);
|
||||
}
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
|
||||
@@ -214,15 +214,12 @@ mod tests {
|
||||
assert!(!furnace(stack("minecraft:iron_ore"), None, 0, 5).idle());
|
||||
}
|
||||
|
||||
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
|
||||
/// data root holding that one recipe and an item table with two fuels: `test:coal`
|
||||
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
|
||||
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-furnace-remainder-{}-{}-{count}",
|
||||
std::process::id(),
|
||||
fuel.replace(':', "-")
|
||||
));
|
||||
/// A data root holding one recipe, `test:ore` into `test:ingot`, and an item table
|
||||
/// with two fuels: `test:coal` (no remainder) and `test:cell` (a content mod's item
|
||||
/// that leaves `test:husk`). The folder is gone when this returns.
|
||||
fn data(name: &str) -> (Items, Recipes, Providers) {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-furnace-{name}-{}", std::process::id(),));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::write(
|
||||
@@ -248,6 +245,57 @@ mod tests {
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let providers = Providers::load(&root);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
(items, recipes, providers)
|
||||
}
|
||||
|
||||
/// The furnace sleeps while `idle`, so its tick must change nothing then: run the
|
||||
/// real tick on every idle case and compare, and check a furnace that can smelt
|
||||
/// isn't idle and does change.
|
||||
#[test]
|
||||
fn an_idle_furnace_tick_changes_nothing() {
|
||||
let (items, recipes, providers) = data("idle");
|
||||
let stack = |id: &str| Some(ItemStack::new(id, 1));
|
||||
let furnace = |input, fuel, output| Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [input, fuel, output],
|
||||
lit_remaining: 0,
|
||||
lit_total: 0,
|
||||
cooking_timer: 0,
|
||||
cooking_total: 200,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
let idle = [
|
||||
furnace(None, None, None),
|
||||
furnace(stack("test:ore"), None, None),
|
||||
furnace(None, stack("test:coal"), None),
|
||||
furnace(None, None, stack("test:ingot")),
|
||||
furnace(stack("test:ingot"), None, stack("test:ingot")),
|
||||
];
|
||||
for before in idle {
|
||||
assert!(before.idle(), "{before:?}");
|
||||
let mut after = before.clone();
|
||||
let mut drops = Vec::new();
|
||||
let lit = after.tick(&recipes, &items, &providers, &mut drops);
|
||||
assert_eq!(lit, None, "{before:?}");
|
||||
assert!(drops.is_empty(), "{before:?}");
|
||||
assert_eq!(format!("{after:?}"), format!("{before:?}"));
|
||||
}
|
||||
let mut busy = furnace(stack("test:ore"), stack("test:coal"), None);
|
||||
assert!(!busy.idle());
|
||||
let before = format!("{busy:?}");
|
||||
assert_eq!(
|
||||
busy.tick(&recipes, &items, &providers, &mut Vec::new()),
|
||||
Some(true)
|
||||
);
|
||||
assert_ne!(format!("{busy:?}"), before);
|
||||
}
|
||||
|
||||
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot` ([`data`]).
|
||||
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
|
||||
let (items, recipes, providers) =
|
||||
data(&format!("remainder-{}-{count}", fuel.replace(':', "-")));
|
||||
let mut furnace = Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [
|
||||
@@ -264,7 +312,6 @@ mod tests {
|
||||
};
|
||||
let mut drops = Vec::new();
|
||||
let lit = furnace.tick(&recipes, &items, &providers, &mut drops);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert_eq!(lit, Some(true), "the fuel lit the furnace");
|
||||
(furnace, drops)
|
||||
}
|
||||
|
||||
@@ -247,6 +247,7 @@ mod native_shaders;
|
||||
mod native_ticks;
|
||||
mod native_visuals;
|
||||
mod native_world_columns;
|
||||
pub mod native_world_draw;
|
||||
mod native_world_edit;
|
||||
mod native_world_read;
|
||||
mod natural_spawning;
|
||||
@@ -259,6 +260,7 @@ mod options;
|
||||
mod ordered_tick;
|
||||
mod packs;
|
||||
mod pad;
|
||||
mod pad_actions;
|
||||
mod pad_menu;
|
||||
mod paintings;
|
||||
mod pandas;
|
||||
@@ -440,6 +442,7 @@ pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_reload_listeners::NativeReloadListener;
|
||||
pub use native_server::NativeExperienceOrb;
|
||||
pub use native_world_columns::NativeColumnBlocks;
|
||||
pub use native_world_draw::NativeWorldDraw;
|
||||
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
|
||||
pub use native_world_read::NativeBlockReadError;
|
||||
use std::{
|
||||
@@ -526,6 +529,9 @@ struct Options {
|
||||
/// `--dedicated`: run as a dedicated server; vanilla's server arguments
|
||||
/// (`--nogui`, `--port`, `--world`, `--universe`, `--initSettings`) follow.
|
||||
dedicated: Option<Vec<String>>,
|
||||
/// `--transfers SETTINGS`: a dedicated server takes a proxy's hand-over requests
|
||||
/// (`server.toml`'s `[transfer]`, which horizon-server passes; lan/arrivals.rs).
|
||||
transfers: Option<horizon_client::lan::arrivals::Settings>,
|
||||
/// `--benchmark SCENARIO`: run the benchmark, write its report and quit.
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): content mods always on.
|
||||
@@ -661,6 +667,7 @@ impl Options {
|
||||
.ok()
|
||||
.filter(|n| !n.is_empty()),
|
||||
dedicated: None,
|
||||
transfers: None,
|
||||
benchmark: None,
|
||||
dev_mods: Vec::new(),
|
||||
execution_role: execution_role::ExecutionRole::Client,
|
||||
@@ -674,7 +681,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --transfers SETTINGS A dedicated server takes a proxy's hand-over requests (server.toml [transfer], docs/server.md)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -777,6 +784,10 @@ impl Options {
|
||||
"--name" => o.player_name = Some(value),
|
||||
"--session-file" => o.session_file = Some(value),
|
||||
"--control-socket" => o.control = Some(value.into()),
|
||||
"--transfers" => {
|
||||
o.transfers =
|
||||
Some(horizon_client::lan::arrivals::Settings::parse(&value)?)
|
||||
}
|
||||
"--time" => o.time = Some(value.parse()?),
|
||||
"--weather" => o.weather = Some(value),
|
||||
"--dimension" => {
|
||||
@@ -1004,9 +1015,11 @@ struct GameLevel<'w> {
|
||||
debug: bool,
|
||||
native_sounds: &'w mut Vec<creatures::NativeSound>,
|
||||
native_particles: &'w mut Vec<lan_particles::QueuedParticle>,
|
||||
/// The tool a mod's machine breaks blocks with (`native_machine_destroy_block`):
|
||||
/// `Block.dropResources` rolls the loot with it instead of with none.
|
||||
drop_tool: Option<ItemStack>,
|
||||
/// The block a mod's machine breaks and the tool it breaks it with
|
||||
/// (`native_machine_destroy_block`): `Block.dropResources` rolls that
|
||||
/// block's loot with the tool instead of with none. Neighbours that pop
|
||||
/// off because of the break drop as with no tool, as in vanilla.
|
||||
drop_tool: Option<((i32, i32, i32), Option<ItemStack>)>,
|
||||
/// Where `Block.dropResources` puts the drops instead of popping them,
|
||||
/// while a mod's machine breaks a block (`native_machine_destroy_block`).
|
||||
drop_into: Option<Vec<ItemStack>>,
|
||||
@@ -1990,6 +2003,15 @@ struct WorldTables {
|
||||
/// The values of the data map types native mods registered
|
||||
/// (`native_data_map_types`).
|
||||
data_maps: native_data_maps::DataMaps,
|
||||
/// The entity type, biome and damage type tags with the packs' over the
|
||||
/// game's own, by registry (`TagLoader.loadTagsForExistingRegistries`):
|
||||
/// what the predicates' `#tag`s of those registries read.
|
||||
pack_tags: HashMap<&'static str, HashMap<String, HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl WorldTables {
|
||||
/// The registries [`WorldTables::pack_tags`] holds.
|
||||
const PACK_TAG_REGISTRIES: [&'static str; 3] = ["entity_type", "worldgen/biome", "damage_type"];
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
@@ -3868,7 +3890,14 @@ impl Game<'_> {
|
||||
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
|
||||
/// particles at the entity.
|
||||
fn honey_slide_particles(&mut self, at: [f64; 3]) {
|
||||
self.honey_entity_particles(at, 53);
|
||||
}
|
||||
/// `HoneyBlock.showParticles`: the crumb count of entity event 53 or 54.
|
||||
fn honey_entity_particles(&mut self, at: [f64; 3], event: i8) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let Some(count) = horizon_client::landing::honey_particle_count(event) else {
|
||||
return;
|
||||
};
|
||||
let Some(honey) = self
|
||||
.pack
|
||||
.assets
|
||||
@@ -3883,7 +3912,7 @@ impl Game<'_> {
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
|
||||
for _ in 0..5 {
|
||||
for _ in 0..count {
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
|
||||
at,
|
||||
@@ -6217,8 +6246,15 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// ServerPlayerGameMode.destroyBlock.
|
||||
// ServerPlayerGameMode.destroyBlock (and MultiPlayerGameMode.destroyBlock
|
||||
// on the client): a GameMasterBlock stays when the player cannot use
|
||||
// game master blocks (Player.canUseGameMasterBlocks).
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !self.can_use_game_master_blocks()
|
||||
&& is_game_master_block(&self.res.facts().get(state).name)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Native mods, once the game allowed the break: a canceled one keeps
|
||||
// the block, and a guest's acknowledgement puts it back in its world.
|
||||
let mut canceled = false;
|
||||
@@ -7671,6 +7707,25 @@ impl Game<'_> {
|
||||
};
|
||||
self.with_level(|b, level, ticks| b.notify_written(level, ticks, position, flags));
|
||||
}
|
||||
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
|
||||
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
|
||||
/// moves only while the world runs.
|
||||
pub(super) fn visual_ticks(&self) -> u64 {
|
||||
if self.clock.still() {
|
||||
self.world.game_time.max(0) as u64
|
||||
} else {
|
||||
self.ticks
|
||||
}
|
||||
}
|
||||
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
|
||||
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
|
||||
pub(super) fn visual_millis(&self) -> i64 {
|
||||
if self.clock.still() {
|
||||
self.visual_ticks() as i64 * 50
|
||||
} else {
|
||||
self.started.elapsed().as_millis() as i64
|
||||
}
|
||||
}
|
||||
fn tick(&mut self) -> Result<()> {
|
||||
if self.agent.hold_server {
|
||||
return Ok(());
|
||||
@@ -7718,8 +7773,9 @@ impl Game<'_> {
|
||||
self.world.weather.tick_timers(&mut self.world.random);
|
||||
}
|
||||
self.world.weather.tick_levels();
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
// ClientLevel.tick: the sky flash fades in a frozen level too.
|
||||
self.tick_weather_effects();
|
||||
let _ = self.finish_player_autosaves(false);
|
||||
// MinecraftServer.tickServer's countdown, then autoSave: three
|
||||
// hundred seconds of ticks at the rate the server runs at now.
|
||||
@@ -8830,6 +8886,7 @@ impl Game<'_> {
|
||||
hud_layout::paint(batch, &g.gui.atlas, &draws);
|
||||
}
|
||||
});
|
||||
let (visual_ticks, visual_millis) = (self.visual_ticks(), self.visual_millis());
|
||||
if !self.creative
|
||||
&& !spectator
|
||||
&& let Some(p) = &mut self.player
|
||||
@@ -8848,8 +8905,8 @@ impl Game<'_> {
|
||||
// the surface, not merely in the water block.
|
||||
let under_water = p.is_eye_in_fluid(horizon_client::motion::WATER);
|
||||
let inputs = hud_layout::HealthInputs {
|
||||
tick: self.ticks as i32,
|
||||
millis: self.started.elapsed().as_millis() as i64,
|
||||
tick: visual_ticks as i32,
|
||||
millis: visual_millis,
|
||||
health: p.health.health(),
|
||||
max_health: p.attribute("minecraft:max_health").unwrap_or(20.0) as f32,
|
||||
absorption: p.health.absorption(),
|
||||
@@ -11640,6 +11697,7 @@ impl Game<'_> {
|
||||
laps.lap("eg_cracks");
|
||||
self.finish_async_entity_culling();
|
||||
laps.lap("eg_culling_finish");
|
||||
self.native_world_geometry(camera, alpha as f32, &mut out);
|
||||
(
|
||||
out,
|
||||
static_block_entities,
|
||||
@@ -15808,6 +15866,10 @@ impl Game<'_> {
|
||||
}
|
||||
// Before any text of this frame: a full glyph region starts over.
|
||||
self.gui.atlas.font.begin_frame();
|
||||
if self.clock.still() {
|
||||
// Obfuscated text picks the same glyphs in a frame of the same world tick.
|
||||
self.gui.atlas.font.reseed(self.visual_ticks() as i64);
|
||||
}
|
||||
self.glint_frame = self.capture_glint_frame();
|
||||
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
|
||||
self.apply_ui_settings();
|
||||
@@ -16433,7 +16495,7 @@ impl Game<'_> {
|
||||
.get(n)
|
||||
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0])
|
||||
}),
|
||||
ticks: self.ticks,
|
||||
ticks: self.visual_ticks(),
|
||||
darkness: self.darkness_lighting(alpha as f32),
|
||||
night_vision: self.night_vision_intensity(alpha as f32),
|
||||
block_flicker: self.fog.block_light_flicker,
|
||||
@@ -16603,7 +16665,7 @@ impl Game<'_> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let millis = self.started.elapsed().as_millis() as i64;
|
||||
let millis = self.visual_millis();
|
||||
Some(horizon_client::gfx::BorderDraw {
|
||||
vertices: self.border_renderer.vertices,
|
||||
rebuilt: self.border_renderer.take_rebuilt(),
|
||||
@@ -17194,7 +17256,7 @@ impl Game<'_> {
|
||||
unloaded.iter().map(|c| (c.x, c.z)).collect();
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
.unload(|p| gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
self.world
|
||||
.block_names
|
||||
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
@@ -18743,7 +18805,15 @@ impl Game<'_> {
|
||||
let started = Instant::now();
|
||||
if self.title.is_none() && !self.pause {
|
||||
horizon_client::spans::time("tick", || self.tick())?;
|
||||
self.tick_ambience();
|
||||
// Cave mood (a shader pack's playerMood) samples on a random
|
||||
// seeded from the clock: under `HORIZON_STILL` it waits for the
|
||||
// world and draws from its tick.
|
||||
if !self.clock.still() {
|
||||
self.tick_ambience();
|
||||
} else if self.clock.runs_normally() {
|
||||
self.ambience.reseed(self.visual_ticks() as i64);
|
||||
self.tick_ambience();
|
||||
}
|
||||
self.tick_fov();
|
||||
}
|
||||
self.tick_win_screen()?;
|
||||
@@ -19946,6 +20016,14 @@ impl Game<'_> {
|
||||
None => self.res.stations(),
|
||||
}
|
||||
}
|
||||
/// The enchantments item names and tooltips read: a server's, with the
|
||||
/// entries its data packs add, else the world's.
|
||||
fn enchantments(&self) -> &horizon_client::enchant::Enchantments {
|
||||
match self.remote.as_ref().and_then(|r| r.enchantments.as_ref()) {
|
||||
Some(e) => e,
|
||||
None => &self.stations().enchantments,
|
||||
}
|
||||
}
|
||||
/// The world's packs' content files, none without packs.
|
||||
/// The biome table the world plays with, `None` before a world opens.
|
||||
fn packed_biomes(&self) -> Option<Arc<Biomes>> {
|
||||
@@ -20025,6 +20103,20 @@ impl Game<'_> {
|
||||
self.res.core.collision.clone(),
|
||||
&causes_suffocation,
|
||||
)?;
|
||||
// A registry whose pack tags don't load keeps the game's own tags
|
||||
// (`in_tag` falls back to them), rather than matching no tag at all.
|
||||
let pack_tags = WorldTables::PACK_TAG_REGISTRIES
|
||||
.into_iter()
|
||||
.filter_map(|registry| {
|
||||
match horizon_client::items::load_tags(root, registry, &overlay) {
|
||||
Ok(tags) => Some((registry, tags)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's {registry} tags are not applied: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
Some(WorldTables {
|
||||
suffocation,
|
||||
@@ -20033,6 +20125,7 @@ impl Game<'_> {
|
||||
recipes,
|
||||
stations,
|
||||
data_maps,
|
||||
pack_tags,
|
||||
}),
|
||||
health,
|
||||
))
|
||||
@@ -22354,6 +22447,19 @@ fn debug_crosshair_drawn(
|
||||
enabled && first_person && !hide_gui && !panorama
|
||||
}
|
||||
|
||||
/// Blocks whose class implements `GameMasterBlock`: the command blocks, structure
|
||||
/// block, jigsaw and the test blocks.
|
||||
fn is_game_master_block(name: &str) -> bool {
|
||||
horizon_client::command_block::Mode::of_block(name).is_some()
|
||||
|| matches!(
|
||||
name,
|
||||
"minecraft:structure_block"
|
||||
| "minecraft:jigsaw"
|
||||
| "minecraft:test_block"
|
||||
| "minecraft:test_instance_block"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -209,8 +209,21 @@ impl adv::World for View<'_, '_> {
|
||||
fn smokey(&self, p: [i32; 3]) -> bool {
|
||||
horizon_client::beehive::smokey(&self.0.mob_world_base(&[]), p)
|
||||
}
|
||||
fn block_entity_nbt(&self, p: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
self.0.block_entity_nbt(p)
|
||||
}
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
|
||||
let items = &self.0.res.items();
|
||||
// The world's packs' tags over the game's own, as the server's
|
||||
// registries bind them (`TagLoader.loadTagsForExistingRegistries`).
|
||||
if let Some(tags) = self
|
||||
.0
|
||||
.world_tables
|
||||
.as_ref()
|
||||
.and_then(|t| t.pack_tags.get(registry))
|
||||
{
|
||||
return tags.get(tag).is_some_and(|t| t.contains(id));
|
||||
}
|
||||
let items = self.0.items();
|
||||
match registry {
|
||||
"item" => items.item_in_tag(tag, id),
|
||||
"block" => items.block_in_tag(tag, id),
|
||||
@@ -871,10 +884,7 @@ impl Game<'_> {
|
||||
.recipe_book
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = self
|
||||
.res
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random);
|
||||
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
|
||||
for s in items {
|
||||
if let Some(rest) = self.give(s)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
|
||||
@@ -518,9 +518,10 @@ impl Game<'_> {
|
||||
let atlas = &self.gui.atlas;
|
||||
let (bx, by) = ((w / 2.0 - 100.0).floor(), h - FOOTER + 6.0);
|
||||
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
|
||||
ui::button(
|
||||
ui::action_button(
|
||||
batch,
|
||||
atlas,
|
||||
horizon_client::widgets::Action::Done,
|
||||
bx,
|
||||
by,
|
||||
200.0,
|
||||
|
||||
@@ -1659,6 +1659,11 @@ impl Game<'_> {
|
||||
if text.is_empty() {
|
||||
return Err("command TEXT".into());
|
||||
}
|
||||
// A native mod's command first, as typed chat does; the
|
||||
// server's console cannot run a client mod's command.
|
||||
if self.client_mod_command(text) {
|
||||
return ok(json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) }));
|
||||
}
|
||||
let command = format!("/{text}");
|
||||
// Single player's console is its server thread's.
|
||||
if let Some(server) = &self.integrated {
|
||||
|
||||
@@ -21,6 +21,48 @@ use horizon_core::{
|
||||
};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Velocity `AbstractMinecart.push` adds: the cart, and a quarter on the stand.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct CartShove {
|
||||
cart_x: f64,
|
||||
cart_z: f64,
|
||||
stand_x: f64,
|
||||
stand_z: f64,
|
||||
}
|
||||
/// `ArmorStand.pushEntities` for one cart. `None` when the cart is not the
|
||||
/// plain rideable kind, the boxes miss, `distanceToSqr` is past 0.2, the
|
||||
/// stand is a passenger of the cart, or the horizontal gap is under
|
||||
/// `1.0E-4F` (where `AbstractMinecart.push` does nothing).
|
||||
fn armor_stand_cart_shove(
|
||||
stand_pos: Vec3,
|
||||
stand_box: Aabb,
|
||||
cart_pos: Vec3,
|
||||
cart_box: Aabb,
|
||||
kind: super::minecarts::CartKind,
|
||||
cart_carries_stand: bool,
|
||||
) -> Option<CartShove> {
|
||||
if kind != super::minecarts::CartKind::Rideable
|
||||
|| cart_carries_stand
|
||||
|| !stand_box.intersects(cart_box)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (dx, dy, dz) = (
|
||||
stand_pos.x - cart_pos.x,
|
||||
stand_pos.y - cart_pos.y,
|
||||
stand_pos.z - cart_pos.z,
|
||||
);
|
||||
if dx * dx + dy * dy + dz * dz > 0.2 {
|
||||
return None;
|
||||
}
|
||||
let (xa, za) = super::minecarts::cart_push_from(stand_pos, cart_pos)?;
|
||||
Some(CartShove {
|
||||
cart_x: -xa,
|
||||
cart_z: -za,
|
||||
stand_x: xa / 4.0,
|
||||
stand_z: za / 4.0,
|
||||
})
|
||||
}
|
||||
/// `EquipmentLayerRenderer.getColorForLayer`: a dyeable layer (one with a
|
||||
/// colour when undyed) takes the stack's `minecraft:dyed_color`, else that
|
||||
/// colour; any other layer stays white, dyed or not. The result is in the
|
||||
@@ -879,6 +921,9 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
// LivingEntity.aiStep calls pushEntities after travel. The stand's
|
||||
// own doPush is empty; only this shove runs.
|
||||
self.push_rideable_minecarts(i);
|
||||
let a = &mut self.armor_stands[i];
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
@@ -889,6 +934,50 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `ArmorStand.pushEntities`: a plain minecart (`RIDABLE_MINECARTS`,
|
||||
/// `AbstractMinecart.isRideable`) whose box meets the stand's is shoved
|
||||
/// when `distanceToSqr` is at most 0.2. `AbstractMinecart.push` of a
|
||||
/// non-cart adds the push to the cart and a quarter of it to the stand
|
||||
/// (`Entity.push(double)` does not consult `isPushable`). A stand with no
|
||||
/// physics (`Marker` or `NoGravity`, `ArmorStand.hasPhysics` / `noPhysics`)
|
||||
/// or one riding that cart does not shove it.
|
||||
fn push_rideable_minecarts(&mut self, i: usize) {
|
||||
let stand = &self.armor_stands[i];
|
||||
if stand.marker || stand.no_gravity {
|
||||
return;
|
||||
}
|
||||
let stand_pos = stand.position;
|
||||
let stand_box = stand.bounds();
|
||||
let vehicle = stand.vehicle;
|
||||
let mut applied = Vec::new();
|
||||
for (j, cart) in self.carts.iter().enumerate() {
|
||||
let Some(shove) = armor_stand_cart_shove(
|
||||
stand_pos,
|
||||
stand_box,
|
||||
cart.position,
|
||||
cart.bounds(),
|
||||
cart.kind,
|
||||
vehicle == Some(cart.id),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
applied.push((j, shove));
|
||||
}
|
||||
let mut stand_x = 0.0;
|
||||
let mut stand_z = 0.0;
|
||||
for (j, shove) in applied {
|
||||
let cart = &mut self.carts[j];
|
||||
cart.velocity.x += shove.cart_x;
|
||||
cart.velocity.z += shove.cart_z;
|
||||
stand_x += shove.stand_x;
|
||||
stand_z += shove.stand_z;
|
||||
}
|
||||
if stand_x != 0.0 || stand_z != 0.0 {
|
||||
let stand = &mut self.armor_stands[i];
|
||||
stand.velocity.x += stand_x;
|
||||
stand.velocity.z += stand_z;
|
||||
}
|
||||
}
|
||||
/// An armor stand is a `LivingEntity`, so the egg under it
|
||||
/// (`getOnPosLegacy`) cracks like under a mob: `TurtleEggBlock.fallOn` one
|
||||
/// landing in 3 (`Entity.move`'s `checkFallDamage`), then `stepOn` one tick
|
||||
@@ -1683,6 +1772,84 @@ mod burn_drop_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod push_tests {
|
||||
use super::super::minecarts::{CartKind, Minecart};
|
||||
use super::*;
|
||||
|
||||
fn boxes(kind: CartKind, cart_at: Vec3) -> (Aabb, Aabb) {
|
||||
let stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
|
||||
let cart = Minecart::new(2, kind, cart_at);
|
||||
(stand.bounds(), cart.bounds())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rideable_minecart_inside_the_0_2_gate_is_shoved() {
|
||||
// 0.25² is inside the 0.2 gate; 0.5² is not. The function's Some/None is the check.
|
||||
let near = Vec3::new(0.25, 0.0, 0.0);
|
||||
let far = Vec3::new(0.5, 0.0, 0.0);
|
||||
let (stand_box, cart_box) = boxes(CartKind::Rideable, near);
|
||||
let shove = armor_stand_cart_shove(
|
||||
Vec3::ZERO,
|
||||
stand_box,
|
||||
near,
|
||||
cart_box,
|
||||
CartKind::Rideable,
|
||||
false,
|
||||
)
|
||||
.expect("a rideable cart 0.25 away is shoved");
|
||||
// AbstractMinecart.push: gap 0.25, length 0.25, pow capped at 1, then 0.1F * 0.5.
|
||||
// The cart moves east, away from the stand; the stand takes a quarter west.
|
||||
let scale = f64::from(0.1_f32) * 0.5;
|
||||
assert_eq!(shove.cart_x, scale);
|
||||
assert_eq!(shove.cart_z, 0.0);
|
||||
assert_eq!(shove.stand_x, -scale / 4.0);
|
||||
assert_eq!(shove.stand_z, 0.0);
|
||||
|
||||
let (stand_box, cart_box) = boxes(CartKind::Rideable, far);
|
||||
assert!(
|
||||
armor_stand_cart_shove(
|
||||
Vec3::ZERO,
|
||||
stand_box,
|
||||
far,
|
||||
cart_box,
|
||||
CartKind::Rideable,
|
||||
false
|
||||
)
|
||||
.is_none(),
|
||||
"past 0.2 the cart stays"
|
||||
);
|
||||
for kind in [
|
||||
CartKind::Chest,
|
||||
CartKind::Hopper,
|
||||
CartKind::Furnace,
|
||||
CartKind::Tnt,
|
||||
CartKind::CommandBlock,
|
||||
CartKind::Spawner,
|
||||
] {
|
||||
let (stand_box, cart_box) = boxes(kind, near);
|
||||
assert!(
|
||||
armor_stand_cart_shove(Vec3::ZERO, stand_box, near, cart_box, kind, false)
|
||||
.is_none(),
|
||||
"{} is not a rideable minecart",
|
||||
kind.id()
|
||||
);
|
||||
}
|
||||
let (stand_box, cart_box) = boxes(CartKind::Rideable, near);
|
||||
assert!(
|
||||
armor_stand_cart_shove(
|
||||
Vec3::ZERO,
|
||||
stand_box,
|
||||
near,
|
||||
cart_box,
|
||||
CartKind::Rideable,
|
||||
true
|
||||
)
|
||||
.is_none(),
|
||||
"a cart does not shove the stand riding it"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod layer_color_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
|
||||
@@ -22,6 +22,20 @@ pub(super) type Pos = (i32, i32, i32);
|
||||
/// `Screen.extractRenderState`'s grey for the labels: `-6250336`.
|
||||
pub(super) const LABEL: u32 = 0xFFA0_A0A0;
|
||||
|
||||
/// The job of an edit screen's footer button `id`, given the ids of its Done and Cancel.
|
||||
pub(super) fn footer_action(
|
||||
id: u8,
|
||||
done: u8,
|
||||
cancel: u8,
|
||||
) -> Option<horizon_client::widgets::Action> {
|
||||
use horizon_client::widgets::Action;
|
||||
match id {
|
||||
_ if id == done => Some(Action::Done),
|
||||
_ if id == cancel => Some(Action::Cancel),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget of an edit screen at GUI pixels.
|
||||
pub(super) enum Item {
|
||||
Label {
|
||||
@@ -48,6 +62,8 @@ pub(super) enum Item {
|
||||
w: f32,
|
||||
label: Text,
|
||||
active: bool,
|
||||
/// Its job on the screen, for the controller.
|
||||
action: Option<horizon_client::widgets::Action>,
|
||||
},
|
||||
/// A multi-line message (`FittingMultiLineTextWidget`), wrapped to `w`
|
||||
/// and cut to what fits `h`.
|
||||
@@ -271,10 +287,14 @@ impl Game<'_> {
|
||||
w: bw,
|
||||
label,
|
||||
active,
|
||||
action,
|
||||
..
|
||||
} = item
|
||||
{
|
||||
let hover = ui::hovered(cursor, *x, *y, *bw, 20.0);
|
||||
if let Some(action) = *action {
|
||||
horizon_client::widgets::declare_action(action, [*x, *y, *bw, 20.0], *active);
|
||||
}
|
||||
ui::button(batch, atlas, *x, *y, *bw, 20.0, label, *active, hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1278,6 +1278,20 @@ impl Game<'_> {
|
||||
hover(x, 196.0, bw, 20.0),
|
||||
);
|
||||
};
|
||||
let action_button = |batch: &mut HudBatch, action, x: f32, bw: f32, label: &str| {
|
||||
ui::action_button(
|
||||
batch,
|
||||
atlas,
|
||||
action,
|
||||
x,
|
||||
196.0,
|
||||
bw,
|
||||
20.0,
|
||||
&Text::literal(label),
|
||||
true,
|
||||
hover(x, 196.0, bw, 20.0),
|
||||
);
|
||||
};
|
||||
match book {
|
||||
BookScreen::Edit {
|
||||
signing: true,
|
||||
@@ -1381,12 +1395,12 @@ impl Game<'_> {
|
||||
&tr("book.finalizeButton", "Sign and Close"),
|
||||
!title.trim().is_empty(),
|
||||
);
|
||||
button(
|
||||
action_button(
|
||||
batch,
|
||||
widgets::Action::Cancel,
|
||||
w / 2.0 + 2.0,
|
||||
98.0,
|
||||
&tr("gui.cancel", "Cancel"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
BookScreen::Edit {
|
||||
@@ -1464,7 +1478,13 @@ impl Game<'_> {
|
||||
&tr("book.signButton", "Sign"),
|
||||
true,
|
||||
);
|
||||
button(batch, w / 2.0 + 2.0, 98.0, &tr("gui.done", "Done"), true);
|
||||
action_button(
|
||||
batch,
|
||||
widgets::Action::Done,
|
||||
w / 2.0 + 2.0,
|
||||
98.0,
|
||||
&tr("gui.done", "Done"),
|
||||
);
|
||||
}
|
||||
BookScreen::View {
|
||||
pages,
|
||||
@@ -1495,7 +1515,13 @@ impl Game<'_> {
|
||||
// LecternScreen.createMenuControls: Done and Take Book only with
|
||||
// build permission, else BookViewScreen's wide Done.
|
||||
if lectern.is_some() && self.may_build() {
|
||||
button(batch, w / 2.0 - 100.0, 98.0, &tr("gui.done", "Done"), true);
|
||||
action_button(
|
||||
batch,
|
||||
widgets::Action::Done,
|
||||
w / 2.0 - 100.0,
|
||||
98.0,
|
||||
&tr("gui.done", "Done"),
|
||||
);
|
||||
button(
|
||||
batch,
|
||||
w / 2.0 + 2.0,
|
||||
@@ -1504,12 +1530,12 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
button(
|
||||
action_button(
|
||||
batch,
|
||||
widgets::Action::Done,
|
||||
(w - 200.0) / 2.0,
|
||||
200.0,
|
||||
&tr("gui.done", "Done"),
|
||||
true,
|
||||
);
|
||||
}
|
||||
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
|
||||
|
||||
@@ -908,7 +908,7 @@ impl Game<'_> {
|
||||
text,
|
||||
indicator,
|
||||
source,
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
}
|
||||
@@ -1073,7 +1073,7 @@ impl Game<'_> {
|
||||
line,
|
||||
None,
|
||||
ChatSource::Player,
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
}
|
||||
@@ -1239,8 +1239,9 @@ impl Game<'_> {
|
||||
Text::translate("chat.copy", &[]),
|
||||
Text::translate("gui.no", &[]),
|
||||
];
|
||||
self.link_prompt_layout(url, (w, h))
|
||||
.draw(batch, atlas, self.cursor, &labels);
|
||||
let layout = self.link_prompt_layout(url, (w, h));
|
||||
layout.declare_no(2);
|
||||
layout.draw(batch, atlas, self.cursor, &labels);
|
||||
}
|
||||
/// The prompt's answer: the link opens on yes; either way the chat is back.
|
||||
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
|
||||
@@ -1330,7 +1331,7 @@ impl Game<'_> {
|
||||
self.ui.chat.hide_indicator = !self.settings.extras.chat_indicator;
|
||||
// The fractional tick makes the smooth chat extra's slide move every
|
||||
// frame; vanilla's fade reads it too.
|
||||
let now = self.ticks as f32 + self.clock.fraction() as f32;
|
||||
let now = self.visual_ticks() as f32 + self.clock.fraction() as f32;
|
||||
self.ui.chat.draw(batch, &self.gui.atlas, now, focused);
|
||||
}
|
||||
/// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the
|
||||
@@ -1670,7 +1671,7 @@ impl Game<'_> {
|
||||
message,
|
||||
None,
|
||||
ChatSource::Client,
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -673,6 +673,14 @@ impl Game<'_> {
|
||||
}
|
||||
for (b, x, y, bw) in CommandBlockScreen::buttons(w, h, s.cart.is_some()) {
|
||||
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
|
||||
let action = match b {
|
||||
Button::Done => Some(horizon_client::widgets::Action::Done),
|
||||
Button::Cancel => Some(horizon_client::widgets::Action::Cancel),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(action) = action {
|
||||
horizon_client::widgets::declare_action(action, [x, y, bw, 20.0], true);
|
||||
}
|
||||
ui::button_as(
|
||||
batch,
|
||||
atlas,
|
||||
|
||||
@@ -341,6 +341,48 @@ impl Game<'_> {
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"player_loop" => {
|
||||
let ticks = op["n"].as_u64().unwrap_or(1);
|
||||
let count = |g: &mut Self| {
|
||||
Ok(g.stats
|
||||
.values
|
||||
.get("minecraft:custom")
|
||||
.and_then(|m| m.get("minecraft:play_time"))
|
||||
.copied()
|
||||
.unwrap_or(0))
|
||||
};
|
||||
let before = self.harness_owner(count)?;
|
||||
self.chat_capture = Some(Vec::new());
|
||||
for _ in 0..ticks {
|
||||
self.tick_server(false)?;
|
||||
}
|
||||
let messages = self.chat_capture.take().unwrap_or_default();
|
||||
let delta = self.harness_owner(count)? - before;
|
||||
if delta != ticks as i32 {
|
||||
return Err(format!(
|
||||
"player loop did not advance play_time: {delta}/{ticks}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let record = json!({"cmd": format!("#player_loop {}",op["label"].as_str().unwrap_or("")), "messages": messages.iter().map(spans).collect::<Vec<_>>(), "results": [[true,delta]]});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"player_state" => {
|
||||
let state = self.harness_owner(|g| {
|
||||
let p = g.player.as_ref().ok_or("player state: no actor")?;
|
||||
let mut loaded = Vec::new();
|
||||
let mut done = std::collections::BTreeMap::new();
|
||||
for a in g.adv.tree.list.iter().filter(|a| a.id.starts_with("h311:")) {
|
||||
loaded.push(a.id.clone()); done.insert(a.id.clone(),g.adv.progress.done(a));
|
||||
}
|
||||
loaded.sort();
|
||||
Ok(json!({"health":p.health.health(),"poison_ticks":g.potions.effects.list.iter().find(|e|e.id=="poison").map_or(0,|e|e.duration),"loaded":loaded,"done":done}))
|
||||
})?;
|
||||
let record = json!({"cmd":format!("#player_state {}",op["label"].as_str().unwrap_or("")),"messages":[],"results":[],"state":state});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"tick" => {
|
||||
self.chat_capture = Some(Vec::new());
|
||||
let paced_start = Instant::now();
|
||||
|
||||
@@ -697,11 +697,13 @@ impl Game<'_> {
|
||||
let block = Text::translate(&format!("block.{block}"), &[]);
|
||||
Text::translate("entity.minecraft.falling_block_type", &[block])
|
||||
}
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or_else(|| Text::translate("entity.", &[]), |m| m.command_name()),
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).map_or_else(
|
||||
|| Text::translate("entity.", &[]),
|
||||
|m| match self.mob_team_of(m) {
|
||||
Some(team) => team.formatted_name(m.command_name()),
|
||||
None => m.command_name(),
|
||||
},
|
||||
),
|
||||
other => {
|
||||
// Entity.getDisplayName: the CustomName component, else the type.
|
||||
let custom = match other {
|
||||
@@ -2835,7 +2837,7 @@ impl Game<'_> {
|
||||
.take()
|
||||
.map(std::sync::Arc::unwrap_or_clone)
|
||||
.unwrap_or_default();
|
||||
extras.rolled_loot.retain(|r| r.pos != key);
|
||||
save::consume_container_loot(&mut extras, key);
|
||||
let taken = rolled.is_some();
|
||||
extras.rolled_loot.extend(rolled);
|
||||
chunk.saved = Some(std::sync::Arc::new(extras));
|
||||
@@ -3342,10 +3344,6 @@ impl Game<'_> {
|
||||
// reopen, so it follows the command.
|
||||
self.level.set_difficulty(i);
|
||||
self.sync_rules();
|
||||
if i == 0 {
|
||||
// Peaceful removes hostile mobs.
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
// MinecraftServer.setDifficulty: every player, guests included, is
|
||||
// sent the new difficulty.
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
@@ -3991,7 +3989,7 @@ impl Game<'_> {
|
||||
line,
|
||||
None,
|
||||
horizon_client::chat::ChatSource::Player,
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
Ok(1)
|
||||
|
||||
@@ -354,6 +354,10 @@ impl Game<'_> {
|
||||
slot.extra.tame = old.extra.tame;
|
||||
slot.extra.owner = old.extra.owner;
|
||||
}
|
||||
// Entity.load mutates the existing living entity; its damage
|
||||
// cooldown and previous-hit amount are not read from saved NBT.
|
||||
slot.invulnerable = old.invulnerable;
|
||||
slot.last_hurt = old.last_hurt;
|
||||
slot.ai = old.ai;
|
||||
// Mob.readAdditionalSaveData updates persistent home fields,
|
||||
// while retaining the rest of the live AI/runtime state.
|
||||
@@ -625,35 +629,34 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Block(p) => {
|
||||
if let Some(tag) = self.moving_piston_data(*p) {
|
||||
return Ok(tag);
|
||||
}
|
||||
if let Some(tag) = self.comparator_data(*p).or_else(|| self.pot_data(*p)) {
|
||||
return Ok(tag);
|
||||
}
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let name = self.data_block_name(*p);
|
||||
let e = self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
|
||||
// BlockEntity.saveWithFullMetadata: no chunk-only keepPacked,
|
||||
// and the lock and custom name the model does not hold.
|
||||
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
|
||||
if let (Some(custom), Tag::Compound(map)) =
|
||||
(self.world.block_names.get(&key), &mut tag)
|
||||
{
|
||||
map.entry("CustomName".into())
|
||||
.or_insert_with(|| Tag::String(custom.as_str().into()));
|
||||
}
|
||||
self.restore_loot_table(key, &mut tag);
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Block(p) => self
|
||||
.block_entity_nbt(*p)
|
||||
.ok_or_else(|| err("commands.data.block.invalid", &[])),
|
||||
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
|
||||
}
|
||||
}
|
||||
/// `BlockEntity.saveWithFullMetadata` of the block entity at `p`: no
|
||||
/// chunk-only keepPacked, and the lock and custom name the model does
|
||||
/// not hold. `None` where there is no block entity.
|
||||
pub(in crate::game) fn block_entity_nbt(&self, p: [i32; 3]) -> Option<Tag> {
|
||||
// A live moving piston is the level's block entity there (#9617).
|
||||
if let Some(tag) = self.moving_piston_data(p) {
|
||||
return Some(tag);
|
||||
}
|
||||
if let Some(tag) = self.comparator_data(p).or_else(|| self.pot_data(p)) {
|
||||
return Some(tag);
|
||||
}
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let name = self.data_block_name(p);
|
||||
let e = self.world.block_entities.get(&key)?;
|
||||
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
|
||||
if let (Some(custom), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) {
|
||||
map.entry("CustomName".into())
|
||||
.or_insert_with(|| Tag::String(custom.as_str().into()));
|
||||
}
|
||||
self.restore_loot_table(key, &mut tag);
|
||||
Some(tag)
|
||||
}
|
||||
pub(in crate::game) fn set_data_of(&mut self, r: &DataRef, tag: Tag) -> R<()> {
|
||||
match r {
|
||||
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
|
||||
@@ -1182,3 +1185,134 @@ impl Game<'_> {
|
||||
Ok(target)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod loot_table_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
save::{self, ChunkExtras, RolledLoot},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
|
||||
const TABLE: &str = "minecraft:chests/simple_dungeon";
|
||||
|
||||
/// The chest as a chunk save writes it, and its slots when that save loads.
|
||||
fn saved_and_reloaded(game: &Game<'_>, pos: (i32, i32, i32)) -> (Tag, Vec<Option<ItemStack>>) {
|
||||
let live = game.world.chunks.get(&(0, 0)).unwrap().clone();
|
||||
let chunk = game.with_entities([&live]).remove(0);
|
||||
let registry = game.pack.assets.registry;
|
||||
let encoded = save::encode(&chunk, registry, game.res.biomes());
|
||||
let Some(Tag::List(list)) = encoded.tag.get("block_entities") else {
|
||||
panic!("no block entities");
|
||||
};
|
||||
let saved = list
|
||||
.iter()
|
||||
.find(|t| save::number(t.get("x")) == Some(i64::from(pos.0)))
|
||||
.unwrap()
|
||||
.clone();
|
||||
let reloaded = save::decode(&encoded, registry, game.res.biomes()).unwrap();
|
||||
let (_, BlockEntity::Chest(slots)) = reloaded
|
||||
.block_entities
|
||||
.iter()
|
||||
.find(|(p, _)| *p == pos)
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("chest");
|
||||
};
|
||||
(saved, slots.clone())
|
||||
}
|
||||
|
||||
/// RandomizableContainer.tryLoadLootTable: a tag without a table loads an
|
||||
/// empty chest that stays so, and one with a table keeps it.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn data_commands_on_an_unopened_loaded_chest_survive_a_save_and_reload() {
|
||||
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.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);
|
||||
|
||||
// Two chests as a load leaves them: rolled, with the table in their source.
|
||||
let removed = (1, 64, 2);
|
||||
let merged = (2, 64, 2);
|
||||
let chest = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:chest")
|
||||
.unwrap();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let mut extras = ChunkExtras::default();
|
||||
for pos in [removed, merged] {
|
||||
let mut slots = vec![None; 27];
|
||||
slots[0] = Some(ItemStack::new("minecraft:gold_ingot", 2));
|
||||
chunk.set(pos.0, pos.1, pos.2, chest.0);
|
||||
extras.rolled_loot.push(RolledLoot {
|
||||
pos,
|
||||
table: TABLE.into(),
|
||||
seed: Some(42),
|
||||
items: save::encode_items(&slots),
|
||||
});
|
||||
extras.block_entity_sources.push((
|
||||
pos,
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:chest".into())),
|
||||
("x", Tag::Int(pos.0)),
|
||||
("y", Tag::Int(pos.1)),
|
||||
("z", Tag::Int(pos.2)),
|
||||
("LootTable", Tag::String(TABLE.into())),
|
||||
("LootTableSeed", Tag::Long(42)),
|
||||
]),
|
||||
));
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(pos, BlockEntity::Chest(slots));
|
||||
}
|
||||
chunk.saved = Some(Arc::new(extras));
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
|
||||
assert!(game.run_command("data remove block 1 64 2 LootTable"));
|
||||
let (saved, slots) = saved_and_reloaded(&game, removed);
|
||||
assert!(
|
||||
saved.get("LootTable").is_none(),
|
||||
"the emptied chest rolls again on load: {saved:?}"
|
||||
);
|
||||
assert!(saved.get("LootTableSeed").is_none());
|
||||
assert!(slots.iter().all(Option::is_none));
|
||||
|
||||
assert!(game.run_command("data merge block 2 64 2 {LootTableSeed:7L}"));
|
||||
let (saved, _) = saved_and_reloaded(&game, merged);
|
||||
assert_eq!(save::text(saved.get("LootTable")).as_deref(), Some(TABLE));
|
||||
assert_eq!(save::number(saved.get("LootTableSeed")), Some(7));
|
||||
assert!(saved.get("Items").is_none());
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,7 @@ impl Game<'_> {
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// Force-loaded chunks too, a mod's held ones as `/forceload`'s: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
// And the players' chunks while they load, as vanilla's server runs
|
||||
// its chunk tasks while it waits for the next tick
|
||||
@@ -255,7 +255,10 @@ impl Game<'_> {
|
||||
// only asked for by the tick's own `update_world`.
|
||||
let players = !paused && self.spawn_wait.is_empty();
|
||||
while self.pregen_runs_here()
|
||||
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
|
||||
|| ((players
|
||||
|| !self.streaming.forced.is_empty()
|
||||
|| !self.streaming.mod_forced.is_empty())
|
||||
&& self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
|
||||
@@ -476,8 +476,13 @@ impl Game<'_> {
|
||||
let r = self.renderer.as_mut().ok_or("no renderer")?;
|
||||
match words.get(1).copied() {
|
||||
None => {}
|
||||
Some("on") => r.set_gpu_culling(true)?,
|
||||
Some("off") => r.set_gpu_culling(false)?,
|
||||
Some(mode @ ("on" | "off")) => {
|
||||
// Refused, with the reason, where it isn't offered.
|
||||
r.set_gpu_culling(mode == "on")?;
|
||||
// The setting, as its switch sets it; not saved here (as
|
||||
// `lod-distance`): settings changes and new renderers keep it.
|
||||
self.settings.horizon.gpu_culling = mode == "on";
|
||||
}
|
||||
Some(other) => return Err(format!("unknown GPU culling mode {other}").into()),
|
||||
}
|
||||
Ok(Some(json!({ "ok": true, "gpu_culling": r.gpu_culling() })))
|
||||
@@ -917,11 +922,10 @@ impl Game<'_> {
|
||||
}
|
||||
"chat" => {
|
||||
let text = line.trim_start().strip_prefix("chat").unwrap_or("").trim();
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(text);
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// As if typed: a client mod's command runs before the line
|
||||
// reaches the server, and the Chat option's restrictions and
|
||||
// the recent list apply to a connected server too.
|
||||
self.handle_chat_input(text);
|
||||
Ok(Some(
|
||||
json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) }),
|
||||
@@ -1328,6 +1332,8 @@ impl Game<'_> {
|
||||
"gpu_culling": r.gpu_culling(),
|
||||
"gpu_frame": r.gpu_frame(),
|
||||
"gpu_fading": r.gpu_fading(),
|
||||
"gpu_culling_offer": format!("{:?}", r.gpu_culling_offer()),
|
||||
"stats_lag": r.stats_lag(),
|
||||
"uploaded_bytes": r.uploaded_bytes,
|
||||
"resident_sections": r.resident_sections(),
|
||||
"arena_bytes": arena_bytes,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod pufferfish_player;
|
||||
|
||||
use super::{
|
||||
Dim, Game, Hand, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
|
||||
native_projectiles::Impact, view_vector,
|
||||
@@ -325,6 +327,9 @@ impl Game<'_> {
|
||||
main_hand: self.held().map(|s| s.item.clone()),
|
||||
off_hand: self.offhand.as_ref().map(|s| s.item.clone()),
|
||||
head: self.armor[0].as_ref().map(|s| s.item.clone()),
|
||||
chest: self.armor[1].as_ref().map(|s| s.item.clone()),
|
||||
legs: self.armor[2].as_ref().map(|s| s.item.clone()),
|
||||
feet: self.armor[3].as_ref().map(|s| s.item.clone()),
|
||||
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
spectator: self.spectator(),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Pufferfish.playerTouch in the real player's tick, independent of fish AI.
|
||||
use super::super::{
|
||||
Game,
|
||||
entity::{Entity, with_entity},
|
||||
};
|
||||
use horizon_client::{mobs::Species, scoreboard::teams_allow_damage};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Pufferfish.playerTouch`: accepted player damage, then poison with this fish as source.
|
||||
pub(in crate::game) fn touch_pufferfish(&mut self) {
|
||||
if self.remote.is_some() || self.spectator() {
|
||||
return;
|
||||
}
|
||||
let Some(player) = self.player.as_ref().filter(|p| !p.health.dead_or_dying()) else {
|
||||
return;
|
||||
};
|
||||
let mut fish = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == Species::Pufferfish && m.alive() && m.extra.size > 0)
|
||||
.peekable();
|
||||
if fish.peek().is_none() {
|
||||
return;
|
||||
}
|
||||
let own = player.bounds();
|
||||
// Player.aiStep's contact query; the existing vehicle bounds are read,
|
||||
// without moving or simulating that vehicle.
|
||||
let vehicle = self.riding.and_then(|id| {
|
||||
self.entity_handles().find_map(
|
||||
|h| with_entity!(self,h,e => (e.id()==id && !e.is_removed()).then(||e.bounds())),
|
||||
)
|
||||
});
|
||||
let query = vehicle.map_or_else(
|
||||
|| own.inflate(Vec3::new(1.0, 0.5, 1.0)),
|
||||
|b| own.union_bounds(b).inflate(Vec3::new(1.0, 0.0, 1.0)),
|
||||
);
|
||||
let touching: Vec<_> = fish
|
||||
.filter(|m| {
|
||||
query.intersects(m.bounds())
|
||||
&& teams_allow_damage(self.mob_team_of(m), self.player_team())
|
||||
})
|
||||
.map(|m| {
|
||||
(
|
||||
m.id,
|
||||
m.extra.size,
|
||||
[m.position.x, m.position.y, m.position.z],
|
||||
self.mob_entity(m),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for (id, puff, at, source) in touching {
|
||||
if !self.hurt_player_from(
|
||||
(1 + puff) as f32,
|
||||
Some(at),
|
||||
"minecraft:mob_attack",
|
||||
Some(Some(id)),
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let changes = self.potions.effects.changes;
|
||||
self.give_player_effect("minecraft:poison", 60 * puff, 0);
|
||||
if self.potions.effects.changes != changes {
|
||||
self.trigger_effects_changed_by(Some(source));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,6 +289,13 @@ impl Game<'_> {
|
||||
];
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let hover = ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0);
|
||||
if i == 3 {
|
||||
horizon_client::widgets::declare_action(
|
||||
horizon_client::widgets::Action::Done,
|
||||
[xs[i], fy, FOOTER_BUTTONS[i], 20.0],
|
||||
true,
|
||||
);
|
||||
}
|
||||
ui::button(
|
||||
batch,
|
||||
atlas,
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(super) mod events;
|
||||
mod management;
|
||||
mod report;
|
||||
mod status;
|
||||
mod transfer;
|
||||
|
||||
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
|
||||
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
|
||||
@@ -985,9 +986,6 @@ impl Game<'_> {
|
||||
};
|
||||
self.level.set_difficulty(self.ui.difficulty);
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
// DedicatedServer.initServer: a legacy `announce-player-achievements`
|
||||
// sets the show_advancement_messages rule once, as the file is read.
|
||||
if let Some(announce) = props.announce_player_achievements {
|
||||
@@ -1025,6 +1023,18 @@ impl Game<'_> {
|
||||
.filter(|b| png_size(b) == Some((64, 64)));
|
||||
// Behind a Velocity proxy: `forwarding.secret` in the server folder, like the proxy's own file.
|
||||
let forwarding_secret = forwarding_secret(online.is_some())?;
|
||||
// `[transfer]` on: a player's only way in must be the proxy, or one who joins this server
|
||||
// directly finds the older copy of itself that a hand-over left here, and items double.
|
||||
let transfers = self
|
||||
.options
|
||||
.transfers
|
||||
.clone()
|
||||
.map(horizon_client::lan::arrivals::Transfers::new);
|
||||
if transfers.is_some() && forwarding_secret.is_none() {
|
||||
return Err("server.toml's [transfer] is on, but players can join this server without the proxy: \
|
||||
turn on Velocity forwarding (forwarding.secret, docs/velocity-forwarding.md) or turn [transfer] off"
|
||||
.into());
|
||||
}
|
||||
let net = NetConfig {
|
||||
bind: (!props.server_ip.is_empty())
|
||||
.then(|| props.server_ip.parse().ok())
|
||||
@@ -1053,6 +1063,7 @@ impl Game<'_> {
|
||||
gate: Some(gate),
|
||||
online,
|
||||
forwarding_secret,
|
||||
arrivals: transfers.as_ref().map(|t| t.expected.clone()),
|
||||
log: Some(NetLog {
|
||||
write: Arc::new(log_from),
|
||||
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
|
||||
@@ -1114,6 +1125,22 @@ impl Game<'_> {
|
||||
log("WARN", "Perhaps a server is already running on that port?");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(transfers) = transfers
|
||||
&& let Some(host) = self.lan.as_mut()
|
||||
{
|
||||
let s = &transfers.settings;
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"Transfers on (server.toml [transfer]): players expected for {} s ({} s at most, {} at once), frozen for {} ticks at most",
|
||||
s.expect.as_secs(),
|
||||
s.max_expect.as_secs(),
|
||||
s.max_expected,
|
||||
s.max_freeze_ticks
|
||||
),
|
||||
);
|
||||
host.hub.transfers = Some(transfers);
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
let anticheat = load_anticheat();
|
||||
let chat_filter = load_chat_filter();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! (including their positions) and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
|
||||
//! `server-events <since>` answers the typed events after that number (events.rs); `management <connection>
|
||||
//! <json>` edits the lists as vanilla's management protocol does (management.rs).
|
||||
//! `player-expect <uuid> [seconds]` and `player-export <uuid> [freeze]` are a proxy's, for a player it moves
|
||||
//! between two servers (transfer.rs).
|
||||
use super::{ServerState, capture_log};
|
||||
use crate::game::Game;
|
||||
use serde_json::{Value, json};
|
||||
@@ -47,6 +49,10 @@ impl Game<'_> {
|
||||
super::events::since(since.trim().parse().unwrap_or(0))
|
||||
}
|
||||
Some(("management", rest)) => self.management(state, rest),
|
||||
// A proxy moving a player here or away (transfer.rs, lan/arrivals.rs).
|
||||
Some((verb, rest)) if verb.starts_with("player-") => {
|
||||
self.player_request(verb, rest)
|
||||
}
|
||||
Some(("console", line)) => {
|
||||
// Command feedback is logged as system chat; the reply carries the text alone.
|
||||
let output: Vec<String> = capture_log(|| self.console_command(state, line))
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! A proxy's requests on the control endpoint, for a player it moves between two servers:
|
||||
//! `player-expect <uuid> [seconds]` (the target is told who is coming) and
|
||||
//! `player-export <uuid> [freeze]` (the source answers with the player's record). They run
|
||||
//! between two ticks, on the server thread, so the player is never caught half-way through a
|
||||
//! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s
|
||||
//! `[transfer]` is off.
|
||||
use crate::game::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::lan::arrivals;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Instant;
|
||||
|
||||
impl Game<'_> {
|
||||
/// A proxy's `player-*` request; the reply is one JSON line, `{"ok": true, ...}` or
|
||||
/// `{"error": "..."}`.
|
||||
pub(super) fn player_request(&mut self, verb: &str, args: &str) -> Value {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return json!({ "error": "the server is not open to players" });
|
||||
};
|
||||
if host.hub.transfers.is_none() {
|
||||
return json!({ "error": arrivals::OFF });
|
||||
}
|
||||
match verb {
|
||||
"player-expect" => match self.lan.as_mut() {
|
||||
Some(host) => arrivals::expect_request(&mut host.hub, args, Instant::now()),
|
||||
None => json!({ "error": "the server is not open to players" }),
|
||||
},
|
||||
"player-export" => self.player_export(args),
|
||||
other => json!({ "error": format!("unknown request {other:?}") }),
|
||||
}
|
||||
}
|
||||
|
||||
/// `player-export <uuid> [freeze]`: closes the player's menus as vanilla does when a menu closes
|
||||
/// (the cursor stack and the crafting grids go back into the inventory), then answers with the
|
||||
/// player's record (`arrivals::export_record`). With `freeze`, a hand-over starts: the player's
|
||||
/// own inputs stop being acted on until it ends, and a later export answers with the same record.
|
||||
fn player_export(&mut self, args: &str) -> Value {
|
||||
let mut parts = args.split_whitespace();
|
||||
let Some(uuid) = parts.next().and_then(arrivals::parse_uuid) else {
|
||||
return json!({ "error": "player-export needs the player's unique id" });
|
||||
};
|
||||
let freeze = match parts.next() {
|
||||
None => false,
|
||||
Some("freeze") => true,
|
||||
Some(_) => return json!({ "error": "the only option of player-export is freeze" }),
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return json!({ "error": "player-export takes a unique id and at most freeze" });
|
||||
}
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return json!({ "error": "the server is not open to players" });
|
||||
};
|
||||
let reply = self.export_from(&mut host, uuid, freeze);
|
||||
self.lan = Some(host);
|
||||
reply
|
||||
}
|
||||
|
||||
fn export_from(&mut self, host: &mut LanHost, uuid: u128, freeze: bool) -> Value {
|
||||
let i = match arrivals::check_exportable(&host.hub, uuid) {
|
||||
Ok(i) => i,
|
||||
Err(why) => return json!({ "error": why }),
|
||||
};
|
||||
// A repeat while the hand-over lasts: the same record, and nothing touched.
|
||||
if let Some(h) = &host.hub.guests[i].handover {
|
||||
return arrivals::export_reply(&host.hub, i, h.record.clone());
|
||||
}
|
||||
let conn = host.hub.guests[i].conn;
|
||||
let window = host.hub.guests[i].menu.as_ref().map(|m| m.window);
|
||||
// The menu closes in the player's own level, as when it leaves (`game/lan.rs`'s close of
|
||||
// every guest): a chest's lid, a villager's trade and a dropped cursor stack belong there.
|
||||
let back = self.dim;
|
||||
let level = Dim::from_id(&host.hub.guests[i].dimension);
|
||||
if let Err(e) = self.enter_level(Some(host), level) {
|
||||
return json!({ "error": format!("the player's level could not be opened: {e}") });
|
||||
}
|
||||
// Nothing is sent to the client by this: it is for a player who is leaving.
|
||||
self.guest_close_all(host, conn);
|
||||
// The inventory's own 2x2 grid too (`InventoryMenu.removed`): `guest_close_all` empties it
|
||||
// only with a real `ContainerClose(0)`, and a frozen client's is dropped. Its items are
|
||||
// not in the player compound, so they would be lost with `player-release moved`.
|
||||
self.lan_crafting_closed(host, conn, Some(0));
|
||||
if let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return json!({ "error": "that player is not connected to this server" });
|
||||
};
|
||||
// The player may stay (a hand-over that does not finish): its client closes the menu too
|
||||
// and shows the inventory as it now is.
|
||||
if let Some(container) = window {
|
||||
host.hub
|
||||
.send_to(conn, &C::ContainerClose(play::ContainerClose { container }));
|
||||
}
|
||||
host.hub.send_inventory(i);
|
||||
// What a mod attached to the player since it joined, as a save writes it.
|
||||
self.guest_attachments_into(&mut host.hub.guests[i]);
|
||||
let advancements = if self.adv.tree.list.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
host.hub.guests[i]
|
||||
.advancements
|
||||
.to_json(&self.adv.tree, horizon_world::DATA_VERSION)
|
||||
};
|
||||
let stats = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.map_or(Value::Null, |p| p.stats.to_json());
|
||||
let records = arrivals::Records {
|
||||
advancements,
|
||||
stats,
|
||||
};
|
||||
match arrivals::export_record(&mut host.hub, i, freeze, records) {
|
||||
Ok(record) => arrivals::export_reply(&host.hub, i, record),
|
||||
Err(e) => json!({ "error": e }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,12 +418,9 @@ impl Game<'_> {
|
||||
widgets::join_for_narration(&[&title.plain(), &message.plain()]),
|
||||
)
|
||||
});
|
||||
Confirm::new(&atlas.font, (w, h), &title, &message).draw(
|
||||
batch,
|
||||
atlas,
|
||||
cursor,
|
||||
[&disconnect, &back],
|
||||
);
|
||||
let confirm = Confirm::new(&atlas.font, (w, h), &title, &message);
|
||||
confirm.declare_no();
|
||||
confirm.draw(batch, atlas, cursor, [&disconnect, &back]);
|
||||
return;
|
||||
}
|
||||
if let Some(since) = self.dialogs.waiting {
|
||||
@@ -450,7 +447,18 @@ impl Game<'_> {
|
||||
if visible {
|
||||
let (bx, by) = (((w - 200.0) / 2.0).trunc(), top + 33.0);
|
||||
let hover = ui::hovered(cursor, bx, by, 200.0, 20.0);
|
||||
ui::button(batch, atlas, bx, by, 200.0, 20.0, &label, active, hover);
|
||||
ui::action_button(
|
||||
batch,
|
||||
atlas,
|
||||
widgets::Action::Back,
|
||||
bx,
|
||||
by,
|
||||
200.0,
|
||||
20.0,
|
||||
&label,
|
||||
active,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -672,6 +680,9 @@ impl Game<'_> {
|
||||
let hover =
|
||||
(!clipped || in_area(cursor.1)) && ui::hovered(cursor, x, y, bw, bh);
|
||||
let b = buttons[index];
|
||||
if open.dialog.escape_button() == Some(index) {
|
||||
widgets::declare_action(widgets::Action::Cancel, rect, true);
|
||||
}
|
||||
ui::button(batch, atlas, x, y, bw, bh, &b.label, true, hover);
|
||||
if let Some(t) = &b.tooltip {
|
||||
widgets::last_narratable(|n| n.hint = Some(t.plain()));
|
||||
|
||||
@@ -569,7 +569,9 @@ impl Dragon {
|
||||
/// (the dragon's type is fire immune) before `EnderDragon.hurt`.
|
||||
/// `direct` is the projectile that hit: a direct `AbstractArrow` or a
|
||||
/// `WindCharge` is the arrow a perched dragon shrugs off, a breeze's
|
||||
/// `BreezeWindCharge` is neither.
|
||||
/// `BreezeWindCharge` is neither. Gameplay calls `hurt_part_from`; this
|
||||
/// is the no-attacker form the tests use.
|
||||
#[cfg(test)]
|
||||
fn hurt_part(
|
||||
&mut self,
|
||||
part: usize,
|
||||
@@ -577,6 +579,19 @@ impl Dragon {
|
||||
counts: Option<HurtSource<'_>>,
|
||||
kind: DamageKind,
|
||||
direct: Option<&ProjectileKind>,
|
||||
) -> Hurt {
|
||||
self.hurt_part_from(part, damage, counts, kind, direct, None)
|
||||
}
|
||||
/// `hurt_part` with the melee attacker's position (`DamageSource.getSourcePosition`,
|
||||
/// the direct entity's feet). `None` for a hit that is not a player's melee.
|
||||
fn hurt_part_from(
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: Option<HurtSource<'_>>,
|
||||
kind: DamageKind,
|
||||
direct: Option<&ProjectileKind>,
|
||||
from: Option<Vec3>,
|
||||
) -> Hurt {
|
||||
if is_fire(kind) {
|
||||
return Hurt::Refused;
|
||||
@@ -587,15 +602,27 @@ impl Dragon {
|
||||
ProjectileKind::Arrow { .. } | ProjectileKind::Trident | ProjectileKind::WindCharge
|
||||
)
|
||||
);
|
||||
self.hurt(part, damage, counts, arrow)
|
||||
self.hurt_from(part, damage, counts, arrow, from)
|
||||
}
|
||||
/// `EnderDragon.hurt(part, source, damage)`: whether the hit landed
|
||||
/// (an arrow is spent, else it turns back). `counts` is the source
|
||||
/// `reallyHurt` takes when `source.getEntity() instanceof Player` or its
|
||||
/// type always hurts dragons (explosions), and none otherwise; `arrow` a
|
||||
/// direct arrow or wind charge, which a perched dragon shrugs off
|
||||
/// (`AbstractDragonSittingPhase.onHurt`).
|
||||
/// (`AbstractDragonSittingPhase.onHurt`). Gameplay calls `hurt_from`.
|
||||
#[cfg(test)]
|
||||
fn hurt(&mut self, part: usize, damage: f32, counts: Option<HurtSource>, arrow: bool) -> Hurt {
|
||||
self.hurt_from(part, damage, counts, arrow, None)
|
||||
}
|
||||
/// `hurt` with the position `dealDefaultKnockback` pushes away from.
|
||||
fn hurt_from(
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: Option<HurtSource>,
|
||||
arrow: bool,
|
||||
from: Option<Vec3>,
|
||||
) -> Hurt {
|
||||
if matches!(self.phase, Phase::Dying { .. }) || !self.alive() {
|
||||
return Hurt::Refused;
|
||||
}
|
||||
@@ -652,6 +679,19 @@ impl Dragon {
|
||||
});
|
||||
}
|
||||
}
|
||||
// `LivingEntity.hurtServer` calls `dealDefaultKnockback` (strength
|
||||
// `0.4F`) on a full hit, before `EnderDragon.hurt` adds sitting damage
|
||||
// and maybe takes off. `from` is the attacker's feet for a player's
|
||||
// melee hit. Explosions are `#no_knockback`; their push is
|
||||
// `explosion_knockback`.
|
||||
if full && let Some(from) = from {
|
||||
self.knockback(
|
||||
f64::from(0.4_f32),
|
||||
from.x - self.position.x,
|
||||
from.z - self.position.z,
|
||||
source,
|
||||
);
|
||||
}
|
||||
if self.phase.sitting() {
|
||||
self.sitting_damage += before - self.health.max(0.0);
|
||||
if self.sitting_damage > 0.25 * MAX_HEALTH {
|
||||
@@ -665,6 +705,43 @@ impl Dragon {
|
||||
}
|
||||
Hurt::Landed { full }
|
||||
}
|
||||
/// `EnderDragon.knockback`: `LivingEntity.knockback` only when the phase
|
||||
/// is not sitting. `(xd, zd)` points at the hit (`dealDefaultKnockback`:
|
||||
/// the source position minus this position). Knockback resistance is the
|
||||
/// mob default, 0. `noPhysics` never sets `onGround`, so the vertical
|
||||
/// component stays.
|
||||
fn knockback(&mut self, power: f64, xd: f64, zd: f64, source: HurtSource<'_>) {
|
||||
if self.phase.sitting() {
|
||||
return;
|
||||
}
|
||||
let mut knock = horizon_client::health::Knock {
|
||||
strength: power,
|
||||
x: xd,
|
||||
z: zd,
|
||||
};
|
||||
if horizon_client::health::knockback_canceled(
|
||||
horizon_client::health::Hurting::Dragon(self.id as i32, source),
|
||||
&mut knock,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let horizon_client::health::Knock {
|
||||
strength: power,
|
||||
x: mut xd,
|
||||
z: mut zd,
|
||||
} = knock;
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
|
||||
xd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
zd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
}
|
||||
let len = (xd * xd + zd * zd).sqrt();
|
||||
let (kx, kz) = (xd / len * power, zd / len * power);
|
||||
let v = self.velocity;
|
||||
self.velocity = Vec3::new(v.x / 2.0 - kx, v.y, v.z / 2.0 - kz);
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` on the dragon: the dragon itself (its
|
||||
/// body takes it), then each part, in `Level.getEntities` order, each by
|
||||
/// its distance and how much of its box the blast sees (`exposure`).
|
||||
@@ -1307,12 +1384,19 @@ impl Game<'_> {
|
||||
) -> Hurt {
|
||||
let by_id = by.map(|who| self.fight_entity(who));
|
||||
let source = counts.then(|| hurt_source(kind, by_id, direct));
|
||||
// `dealDefaultKnockback` for a player's melee: away from the attacker's
|
||||
// feet. The scope names that player to the knockback hook.
|
||||
let _attacker = (kind == DamageKind::PlayerAttack)
|
||||
.then(|| self.knockback_scope(self.player.as_ref().map_or(0, |p| p.entity_id)));
|
||||
let from = (kind == DamageKind::PlayerAttack)
|
||||
.then(|| self.player.as_ref().map(|p| p.position))
|
||||
.flatten();
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return Hurt::Refused;
|
||||
};
|
||||
let was_down = d.down();
|
||||
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
|
||||
let hurt = d.hurt_part(part, damage, source, kind, direct);
|
||||
let hurt = d.hurt_part_from(part, damage, source, kind, direct, from);
|
||||
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
|
||||
// (100 ticks); a hit the damage cooldown absorbs returns before that.
|
||||
if counts
|
||||
@@ -2476,12 +2560,31 @@ impl Game<'_> {
|
||||
if drops {
|
||||
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.2) as u32);
|
||||
}
|
||||
let (id, at) = self.dragon.as_ref().map_or((0, p), |d| (d.id, d.position));
|
||||
self.dragon = None;
|
||||
if fight {
|
||||
self.dragon_killed();
|
||||
}
|
||||
// remove, then gameEvent(ENTITY_DIE): with no health left,
|
||||
// `Warden.canTargetEntity` refuses the dragon, so no warden hears it.
|
||||
self.dragon_died_event(id, at, false);
|
||||
}
|
||||
}
|
||||
/// `Entity.gameEvent(GameEvent.ENTITY_DIE)` for the removed dragon: sculk
|
||||
/// sensors (and, while it has health, wardens) hear it, and the nearest
|
||||
/// catalyst blooms (`CatalystListener`). It takes no experience: the
|
||||
/// dragon's `xpReward` is 0, its orbs come from `tickDeath`.
|
||||
fn dragon_died_event(&mut self, id: u32, at: Vec3, targetable: bool) {
|
||||
let at = [at.x, at.y, at.z];
|
||||
let ctx = horizon_client::sculk::Context {
|
||||
source: Some(Who::Mob(id)),
|
||||
targetable,
|
||||
living: true,
|
||||
..Default::default()
|
||||
};
|
||||
self.post_game_event("minecraft:entity_die", at, ctx);
|
||||
self.catalyst_takes_experience(at, |_| 0);
|
||||
}
|
||||
/// `EnderDragonFight.setDragonKilled`.
|
||||
fn dragon_killed(&mut self) {
|
||||
let _ = self.activate_exit_portal();
|
||||
@@ -3154,7 +3257,7 @@ impl Game<'_> {
|
||||
if let Some(c) = d.nearest_crystal.and_then(|i| self.crystals.get(i)) {
|
||||
let bob = f64::from(horizon_client::end_crystal::y(c.time as f32 + a));
|
||||
let to = glam::DVec3::new(c.position.x, c.position.y + bob, c.position.z);
|
||||
self.crystal_beam(pos, to - pos, self.ticks as f32 + a, camera, out);
|
||||
self.crystal_beam(pos, to - pos, self.visual_ticks() as f32 + a, camera, out);
|
||||
}
|
||||
}
|
||||
/// `EnderDragonRenderer.submitRays`: untextured, additive death rays.
|
||||
@@ -3594,7 +3697,10 @@ impl Game<'_> {
|
||||
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
|
||||
// Only the fight's own dragon (`dragonFight`) ends it.
|
||||
let fight = self.is_fight_dragon();
|
||||
self.dragon = None;
|
||||
if let Some(d) = self.dragon.take() {
|
||||
// remove, then gameEvent(ENTITY_DIE), before the fight hears of it.
|
||||
self.dragon_died_event(d.id, d.position, d.alive());
|
||||
}
|
||||
if fight {
|
||||
self.dragon_killed();
|
||||
}
|
||||
@@ -4755,6 +4861,49 @@ mod tests {
|
||||
assert_eq!(d.health, MAX_HEALTH - 9.0);
|
||||
}
|
||||
|
||||
/// `EnderDragon.knockback` calls `LivingEntity.knockback` only when the
|
||||
/// phase is not sitting. `dealDefaultKnockback` uses `0.4F` away from the
|
||||
/// attacker's feet. Resistance is 0 and `onGround` stays false
|
||||
/// (`noPhysics`), so from velocity `(1, -0.3, 0.5)` and a source 3 by 4
|
||||
/// blocks away the push is `(0.5 - 0.6p, -0.3, 0.25 - 0.8p)` with
|
||||
/// `p = (double) 0.4F`. A perched hit, including one that makes it take
|
||||
/// off, leaves the velocity alone. A hit inside the damage cooldown is
|
||||
/// not a full hit, so it does not push either.
|
||||
#[test]
|
||||
fn a_melee_hit_knocks_a_flying_dragon_and_not_a_perched_one() {
|
||||
let from = Vec3::new(3.0, 70.0, 4.0);
|
||||
let velocity = Vec3::new(1.0, -0.3, 0.5);
|
||||
let power = f64::from(0.4_f32);
|
||||
let pushed = Vec3::new(0.5 - 0.6 * power, -0.3, 0.25 - 0.8 * power);
|
||||
|
||||
let mut perched = dragon(Phase::Scanning { time: 0 });
|
||||
perched.velocity = velocity;
|
||||
assert_eq!(
|
||||
perched.hurt_from(0, 9.0, HIT, false, Some(from)),
|
||||
Hurt::Landed { full: true }
|
||||
);
|
||||
assert_eq!(perched.velocity, velocity);
|
||||
|
||||
let mut taking_off = dragon(Phase::Flaming { ticks: 0 });
|
||||
taking_off.velocity = velocity;
|
||||
taking_off.hurt_from(0, 60.0, HIT, false, Some(from));
|
||||
assert!(matches!(taking_off.phase, Phase::Takeoff { .. }));
|
||||
assert_eq!(taking_off.velocity, velocity);
|
||||
|
||||
let mut flying = dragon(flying());
|
||||
flying.velocity = velocity;
|
||||
assert_eq!(
|
||||
flying.hurt_from(0, 9.0, HIT, false, Some(from)),
|
||||
Hurt::Landed { full: true }
|
||||
);
|
||||
assert_eq!(flying.velocity, pushed);
|
||||
assert_eq!(
|
||||
flying.hurt_from(0, 20.0, HIT, false, Some(from)),
|
||||
Hurt::Landed { full: false }
|
||||
);
|
||||
assert_eq!(flying.velocity, pushed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_refuses_fire() {
|
||||
// EnderDragonPart.hurtServer: the dragon is fire immune, so a
|
||||
|
||||
@@ -62,7 +62,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn sound_entity_state(&self, id: u32) -> Option<SoundEntityState> {
|
||||
pub(super) fn sound_entity_state(&mut self, id: u32) -> Option<SoundEntityState> {
|
||||
if self.remote.is_some() {
|
||||
return self.remote_sound_entity_state(id as i32);
|
||||
}
|
||||
|
||||
@@ -345,13 +345,23 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ParticleType::Firework), at, v);
|
||||
}
|
||||
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
|
||||
/// `FireworkRocketItem.use` while gliding: drop the player's leads, then a
|
||||
/// rocket attached to the player.
|
||||
pub(super) fn boost_firework(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:firework_rocket"
|
||||
|| !self.player.as_ref().is_some_and(|p| p.fall_flying)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Server path of `use`: `dropAllLeashConnections(null)`, and when that
|
||||
// drops anything `playSound(null, player, LEAD_BREAK, NEUTRAL, 1, 1)`.
|
||||
// `SoundEvents.LEAD_BREAK` is `minecraft:item.lead.break`. A position
|
||||
// is what the server thread forwards, so the window hears the snap.
|
||||
if self.drop_all_leash_connections()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
{
|
||||
self.lead_sound("break", [p.position.x, p.position.y, p.position.z]);
|
||||
}
|
||||
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let mut rocket = self.new_rocket(at, stack, true);
|
||||
rocket.owner = Some(Who::Player);
|
||||
@@ -759,5 +769,102 @@ mod tests {
|
||||
assert!(super::use_on_passes(true));
|
||||
assert!(!super::use_on_passes(false));
|
||||
}
|
||||
|
||||
/// `FireworkRocketItem.use` on the server, while fall-flying: a lead on a
|
||||
/// mob within the 32-block box drops as an item and one
|
||||
/// `minecraft:item.lead.break` plays at the player's feet. A lead outside
|
||||
/// that box, or held for another player, stays. Standing still does neither.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_gliding_firework_drops_leads_and_plays_lead_break() {
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::mobs::{Mob, leash::Holder};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
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.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = super::Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, -3.25))
|
||||
.unwrap(),
|
||||
);
|
||||
let feet = {
|
||||
let p = game.player.as_ref().expect("a player").position;
|
||||
[p.x, p.y, p.z]
|
||||
};
|
||||
let kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:cow")
|
||||
.expect("cow")
|
||||
.clone();
|
||||
let me = game.acting_uuid();
|
||||
let someone_else = [me[0].wrapping_add(1), me[1], me[2], me[3]];
|
||||
let mut leashed = |id, at: Vec3, owner: [i32; 4]| {
|
||||
let mut mob = Mob::new(id, kind.clone(), at, 0.0);
|
||||
mob.extra.leash = Some(Holder::Player);
|
||||
mob.extra.leash_player = Some(owner);
|
||||
game.mobs.push(mob);
|
||||
};
|
||||
let here = Vec3::new(feet[0] + 1.0, feet[1], feet[2]);
|
||||
leashed(1, here, me);
|
||||
leashed(2, Vec3::new(feet[0] + 40.0, feet[1], feet[2]), me);
|
||||
leashed(3, here, someone_else);
|
||||
game.inventory[game.selected] = Some(super::ItemStack::new("minecraft:firework_rocket", 1));
|
||||
let stack = game.inventory[game.selected].clone().unwrap();
|
||||
game.audio.capture = Some(Vec::new());
|
||||
|
||||
assert!(!game.boost_firework(&stack));
|
||||
assert_eq!(game.mobs[0].extra.leash, Some(Holder::Player));
|
||||
assert!(game.dropped.is_empty());
|
||||
assert!(game.audio.capture.as_ref().unwrap().is_empty());
|
||||
|
||||
game.player.as_mut().unwrap().fall_flying = true;
|
||||
assert!(game.boost_firework(&stack));
|
||||
assert!(
|
||||
game.mobs[0].extra.leash.is_none(),
|
||||
"the mob on the lead comes free"
|
||||
);
|
||||
assert_eq!(game.mobs[1].extra.leash, Some(Holder::Player));
|
||||
assert_eq!(game.mobs[2].extra.leash, Some(Holder::Player));
|
||||
let leads: Vec<_> = game
|
||||
.dropped
|
||||
.iter()
|
||||
.filter(|d| &*d.stack.item == "minecraft:lead")
|
||||
.collect();
|
||||
assert_eq!(leads.len(), 1);
|
||||
let heard = game.audio.capture.take().unwrap_or_default();
|
||||
let snaps: Vec<_> = heard
|
||||
.into_iter()
|
||||
.filter(|s| s.event == "minecraft:item.lead.break")
|
||||
.map(|s| (s.position, s.volume, s.pitch))
|
||||
.collect();
|
||||
assert_eq!(snaps, vec![(feet, 1.0, 1.0)]);
|
||||
}
|
||||
}
|
||||
horizon_client::impl_joined!(Rocket);
|
||||
@@ -10,8 +10,6 @@ use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A baked frame model: (corners in block units, unit UVs, sprite) per quad.
|
||||
pub(super) type FrameModel = Vec<([[f32; 3]; 4], [[f32; 2]; 4], String)>;
|
||||
|
||||
@@ -165,12 +163,21 @@ impl Game<'_> {
|
||||
"minecraft:glow_item_frame" => true,
|
||||
_ => return false,
|
||||
};
|
||||
let invisible = stack.extra.as_ref().is_some_and(|extra| {
|
||||
extra.other.iter().any(|(k, v)| {
|
||||
(k == "minecraft:entity_data" || k == "entity_data")
|
||||
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
|
||||
let entity_data = stack.extra.as_ref().and_then(|extra| {
|
||||
extra.other.iter().find_map(|(k, v)| {
|
||||
(k == "minecraft:entity_data" || k == "entity_data").then_some(v)
|
||||
})
|
||||
});
|
||||
let invisible = entity_data
|
||||
.and_then(|v| horizon_client::save::number(v.get("Invisible")))
|
||||
.unwrap_or(0)
|
||||
!= 0;
|
||||
// `applyComponentsFromItemStack` sets `CUSTOM_NAME`, then `entity_data`
|
||||
// loads (`updateCustomEntityTag`): a missing `CustomName` clears it.
|
||||
let custom_name = match entity_data {
|
||||
Some(data) => data.get("CustomName").cloned(),
|
||||
None => stack.data().and_then(|d| d.custom_name.clone()),
|
||||
};
|
||||
let pos: [i32; 3] = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
|
||||
// `ItemFrameItem.mayPlace`: `Level.isInsideBuildHeight(blockPos)`, so a frame is not
|
||||
// placed on the top face of the highest block or under the lowest one.
|
||||
@@ -191,6 +198,7 @@ impl Game<'_> {
|
||||
fixed: false,
|
||||
invisible,
|
||||
check_interval: 0,
|
||||
custom_name,
|
||||
};
|
||||
if !self.frame_survives(&frame) {
|
||||
return false;
|
||||
@@ -384,12 +392,8 @@ impl Game<'_> {
|
||||
let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15];
|
||||
let mut drops = Vec::new();
|
||||
if with_frame {
|
||||
drops.push(ItemStack {
|
||||
item: Arc::from(f.kind()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
// `ItemFrame.getFrameItemStackWithData` (glow frames share it).
|
||||
drops.push(f.item_stack_with_data());
|
||||
}
|
||||
if let Some(item) = f.item.clone()
|
||||
&& self.world.random.next_float() < f.drop_chance
|
||||
|
||||
@@ -142,6 +142,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let mut announce = false;
|
||||
let mut stopped = None;
|
||||
for event in sharing.host.poll() {
|
||||
match event {
|
||||
HostEvent::Code(code) => {
|
||||
@@ -165,8 +166,19 @@ impl Game<'_> {
|
||||
HostEvent::Connected { direct: true, .. } => sharing.paths.0 += 1,
|
||||
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
|
||||
HostEvent::Disconnected { .. } => {}
|
||||
HostEvent::RelayStopped => {
|
||||
let text = lang::get_or(
|
||||
"horizon.friends.relay_stopped",
|
||||
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
|
||||
);
|
||||
sharing.notice = Some(text.clone());
|
||||
stopped = Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
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",
|
||||
|
||||
@@ -78,6 +78,12 @@ impl ItemEnv for Env<'_, '_> {
|
||||
local_time(pattern)
|
||||
}
|
||||
fn random_float(&self) -> f32 {
|
||||
if self.game.clock.still() {
|
||||
// The same draw for a frame of the same world tick.
|
||||
let mixed =
|
||||
(self.game.visual_ticks() ^ 0x9e37_79b9).wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
return (mixed >> 40) as f32 / (1u64 << 24) as f32;
|
||||
}
|
||||
let nanos = self.game.started.elapsed().subsec_nanos();
|
||||
(nanos % 1_000_000) as f32 / 1_000_000.0
|
||||
}
|
||||
@@ -395,7 +401,11 @@ impl Game<'_> {
|
||||
/// frame, not per item (`GameRenderer.extractOptions`).
|
||||
pub(super) fn capture_glint_frame(&self) -> item_render::GlintFrame {
|
||||
item_render::GlintFrame {
|
||||
millis: self.started.elapsed().as_secs_f32() * 1000.0,
|
||||
millis: if self.clock.still() {
|
||||
self.visual_millis() as f32
|
||||
} else {
|
||||
self.started.elapsed().as_secs_f32() * 1000.0
|
||||
},
|
||||
speed: self.settings.file.double("glintSpeed") as f32,
|
||||
alpha: self.settings.file.double("glintStrength") as f32,
|
||||
sprite: self
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! state, priorities and joint, with the Generate button and its levels.
|
||||
use super::{
|
||||
Game,
|
||||
block_editor::{Editor, Item, Outcome, Pos, cycle, on_off},
|
||||
block_editor::{Editor, Item, Outcome, Pos, cycle, footer_action, on_off},
|
||||
};
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
@@ -123,6 +123,7 @@ impl Editor for JigsawEditor {
|
||||
w: bw,
|
||||
label: text,
|
||||
active,
|
||||
action: footer_action(id, DONE, CANCEL),
|
||||
};
|
||||
let label = |key: &str, x: f32, y: f32| Item::Label {
|
||||
text: tr(key),
|
||||
|
||||
@@ -579,6 +579,13 @@ fn mob_meta(
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (18, the first entry
|
||||
// after AgeableMob's two). The total `boost()` stored in `counter2`.
|
||||
// The default 0 is not registered: vanilla never writes it back, and the
|
||||
// rider's client finishes the timer `onSynced` started.
|
||||
if matches!(kind, "minecraft:pig" | "minecraft:strider") && e.counter2 > 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.counter2)));
|
||||
}
|
||||
mob_variant_meta(m, hub, meta, defaults);
|
||||
mob_raider_meta(m, meta, defaults);
|
||||
match kind {
|
||||
@@ -2829,10 +2836,13 @@ impl Game<'_> {
|
||||
if event == 21 && self.options.execution_role.is_client() {
|
||||
self.start_guardian_sound(id as u32);
|
||||
}
|
||||
// Event 53: a mob sliding down a honey block sheds honey particles
|
||||
// (HoneyBlock.showSlideParticles), drawn by the client that runs the world.
|
||||
if event == 53 && self.options.execution_role.is_client() {
|
||||
self.honey_slide_particles(at);
|
||||
// Events 53 and 54: honey crumbs while sliding, and the landing burst
|
||||
// (`HoneyBlock.showSlideParticles` / `showJumpParticles`), drawn by the
|
||||
// client that runs the world.
|
||||
if self.options.execution_role.is_client()
|
||||
&& horizon_client::landing::honey_particle_count(event).is_some()
|
||||
{
|
||||
self.honey_entity_particles(at, event);
|
||||
}
|
||||
// Event 2: a mob's spear hit felt; the same window hears its own mob's hit.
|
||||
if event == 2 && self.options.execution_role.is_client() {
|
||||
|
||||
@@ -2493,9 +2493,6 @@ impl Game<'_> {
|
||||
// survives the reopen, so it follows the change.
|
||||
self.level.set_difficulty(self.ui.difficulty);
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
host.hub
|
||||
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
@@ -4585,6 +4582,13 @@ impl Game<'_> {
|
||||
health: &game.health_resources,
|
||||
}
|
||||
.server_travel();
|
||||
// Player.aiStep's entity contacts run with the player's body,
|
||||
// even when the level and its mobs are frozen. Flush their damage
|
||||
// events and marked velocity through the existing guest path.
|
||||
game.touch_pufferfish();
|
||||
let Some(player) = game.player.as_mut() else {
|
||||
return (events, None);
|
||||
};
|
||||
// Landing damage from this travel reaches the tracker too.
|
||||
events.append(&mut player.events);
|
||||
// A hurt since the last tick marked the velocity (Entity.markHurt):
|
||||
@@ -4662,20 +4666,33 @@ impl Game<'_> {
|
||||
host.hub.sound(name, at, volume, pitch, source, None);
|
||||
}
|
||||
}
|
||||
// LivingEntity.causeFallDamage: Player.playSound skips the
|
||||
// guest, whose own client plays it; everyone else hears it.
|
||||
// LivingEntity.causeFallDamage, and HoneyBlock.fallOn's
|
||||
// `entity.playSound`: Player.playSound skips the guest, whose
|
||||
// own client plays it; everyone else hears it.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
pitch,
|
||||
..
|
||||
}) => {
|
||||
if let Some(name) = sound.player_fall_sound() {
|
||||
if let Some(name) = sound
|
||||
.player_fall_sound()
|
||||
.or_else(|| sound.honey_landing_sound())
|
||||
{
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
self.audio.play_unshared(name, Some(at), volume, pitch);
|
||||
host.hub.sound(name, at, volume, pitch, source, Some(conn));
|
||||
}
|
||||
}
|
||||
// HoneyBlock.fallOn: entity event 54, including the player who
|
||||
// landed (`broadcastEntityEvent` has no excluded player).
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Broadcast(event))
|
||||
if horizon_client::landing::honey_particle_count(event as i8).is_some() =>
|
||||
{
|
||||
if let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) {
|
||||
host.hub.entity_event(id, event as i8, at);
|
||||
}
|
||||
}
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
// PowderSnowBlock.entityInside: every player around sees the
|
||||
// snowflake (`ServerLevel.sendParticles`).
|
||||
|
||||
@@ -476,6 +476,9 @@ mod tests {
|
||||
main_hand: None,
|
||||
off_hand: None,
|
||||
head: None,
|
||||
chest: None,
|
||||
legs: None,
|
||||
feet: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
|
||||
@@ -960,6 +960,7 @@ mod tests {
|
||||
sender: PlayerSender::new(tx_near, std::sync::Arc::default()),
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
1,
|
||||
[5.0, 64.0, 5.0],
|
||||
@@ -981,6 +982,7 @@ mod tests {
|
||||
sender: PlayerSender::new(tx_far, std::sync::Arc::default()),
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25566".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
2,
|
||||
[100.0, 64.0, 100.0],
|
||||
|
||||
@@ -818,7 +818,18 @@ impl Game<'_> {
|
||||
});
|
||||
let hover = ui::hovered(self.cursor, dx, dy, 200.0, 20.0) || is_focused;
|
||||
let label = Text::translate("gui.done", &[]);
|
||||
ui::button(batch, atlas, dx, dy, 200.0, 20.0, &label, true, hover);
|
||||
ui::action_button(
|
||||
batch,
|
||||
atlas,
|
||||
horizon_client::widgets::Action::Done,
|
||||
dx,
|
||||
dy,
|
||||
200.0,
|
||||
20.0,
|
||||
&label,
|
||||
true,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_options_view(&self, o: &mut WorldOptions, batch: &mut HudBatch, cursor: (f32, f32)) {
|
||||
@@ -887,6 +898,10 @@ impl Game<'_> {
|
||||
}
|
||||
if b == Button::Lock {
|
||||
widgets::narratable(|| o.narration(b, [bx, by, 20.0, 20.0], tr).hovered(hover));
|
||||
// An icon, but a button: the D-pad and Tab stop on it as on the others.
|
||||
if active {
|
||||
widgets::target([bx, by, 20.0, 20.0]);
|
||||
}
|
||||
let lock = if o.locked { "locked" } else { "unlocked" };
|
||||
let state = match (active, hover) {
|
||||
(false, _) => "_disabled",
|
||||
@@ -971,7 +986,22 @@ impl Game<'_> {
|
||||
let active = o.active(b);
|
||||
let is_focused = o.focused_button == Some(b);
|
||||
let hover = active && (ui::hovered(cursor, bx, by, bw, 20.0) || is_focused);
|
||||
ui::button(batch, atlas, bx, by, bw, 20.0, &o.label(b), active, hover);
|
||||
let action = match b {
|
||||
Button::Apply => horizon_client::widgets::Action::Apply,
|
||||
_ => horizon_client::widgets::Action::Cancel,
|
||||
};
|
||||
ui::action_button(
|
||||
batch,
|
||||
atlas,
|
||||
action,
|
||||
bx,
|
||||
by,
|
||||
bw,
|
||||
20.0,
|
||||
&o.label(b),
|
||||
active,
|
||||
hover,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -197,6 +197,38 @@ impl Game<'_> {
|
||||
pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) {
|
||||
self.drop_leash(id, drop, sound, false);
|
||||
}
|
||||
/// `Entity.dropAllLeashConnections`: every mob and boat leashed to the
|
||||
/// acting player, inside the 32-block box around the player's centre,
|
||||
/// drops its lead. Then `GameEvent.SHEAR`. Whether anything dropped.
|
||||
/// The player is not `Leashable`, so there is no lead of their own.
|
||||
pub(super) fn drop_all_leash_connections(&mut self) -> bool {
|
||||
let center = self.player_center();
|
||||
let mobs: Vec<u32> = self
|
||||
.player_leashed(center)
|
||||
.into_iter()
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
let boats = self.player_led_boats(center);
|
||||
if mobs.is_empty() && boats.is_empty() {
|
||||
return false;
|
||||
}
|
||||
for id in mobs {
|
||||
// One snap is played by the caller (`SoundEvents.LEAD_BREAK`),
|
||||
// not a break sound on each leashed entity.
|
||||
self.break_leash(id, true, false);
|
||||
}
|
||||
self.drop_boat_leashes(&boats);
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map_or([center.x, center.y, center.z], |p| {
|
||||
[p.position.x, p.position.y, p.position.z]
|
||||
});
|
||||
// `gameEvent(GameEvent.SHEAR, null)`: the player is the position, not the source.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:shear", at, ctx);
|
||||
true
|
||||
}
|
||||
/// `break_leash` by the acting player's use (untying it, shears).
|
||||
fn break_leash_used(&mut self, id: u32, drop: bool) {
|
||||
self.drop_leash(id, drop, false, true);
|
||||
|
||||
@@ -492,9 +492,8 @@ impl Game<'_> {
|
||||
self.tick()
|
||||
} else {
|
||||
let running = running.unwrap_or_else(|| self.clock.world_runs());
|
||||
if running {
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
// ClientLevel.tick: the sky flash fades in a frozen level too.
|
||||
self.tick_weather_effects();
|
||||
self.tick_world(running)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ impl Game<'_> {
|
||||
if progress != *last {
|
||||
*last = progress;
|
||||
if *seen {
|
||||
self.experience_display.2 = self.ticks;
|
||||
self.experience_display.2 = self.visual_ticks().max(1);
|
||||
} else {
|
||||
*seen = true;
|
||||
}
|
||||
@@ -48,13 +48,14 @@ impl Game<'_> {
|
||||
super::stations::Kind::Enchanting | super::stations::Kind::Anvil
|
||||
)
|
||||
}) {
|
||||
self.experience_display.2 = self.ticks;
|
||||
self.experience_display.2 = self.visual_ticks().max(1);
|
||||
}
|
||||
}
|
||||
/// `Hud.willPrioritizeExperienceInfo`.
|
||||
/// `Hud.willPrioritizeExperienceInfo`. A start of 0 is none: the stamps above are at least 1
|
||||
/// (a new world under `HORIZON_STILL` is still at tick 0).
|
||||
pub(super) fn experience_recent(&self) -> bool {
|
||||
let (_, _, start) = self.experience_display;
|
||||
start != 0 && start + 100 > self.ticks
|
||||
start != 0 && start + 100 > self.visual_ticks()
|
||||
}
|
||||
/// The local player's UUID, as servers know it.
|
||||
fn own_uuid(&self) -> u128 {
|
||||
@@ -119,7 +120,7 @@ impl Game<'_> {
|
||||
project: &project,
|
||||
entity: &entity,
|
||||
styles: &self.waypoint_styles,
|
||||
tick: self.ticks,
|
||||
tick: self.visual_ticks(),
|
||||
};
|
||||
self.waypoints.draw(&view)
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
}
|
||||
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
|
||||
/// the push the cart loses (the entity gets a quarter of it back).
|
||||
fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
|
||||
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
|
||||
let (xa, za) = (feet.x - cart.x, feet.z - cart.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < f64::from(1.0e-4f32) {
|
||||
|
||||
@@ -148,19 +148,9 @@ impl Game<'_> {
|
||||
/// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it
|
||||
/// carries on.
|
||||
pub(super) fn tick_sound_engine(&mut self) {
|
||||
for entity in self.audio.engine.bound_entities() {
|
||||
let previous = entity.state();
|
||||
if previous.removed {
|
||||
continue;
|
||||
}
|
||||
let state = self.sound_entity_state(entity.id);
|
||||
entity.update(
|
||||
state.unwrap_or(horizon_client::sound_engine::SoundEntityState {
|
||||
removed: true,
|
||||
..previous
|
||||
}),
|
||||
);
|
||||
}
|
||||
update_bound_entities(self.audio.engine.bound_entities(), |id| {
|
||||
self.sound_entity_state(id)
|
||||
});
|
||||
|
||||
if self.pause != self.sound_paused {
|
||||
if self.pause {
|
||||
@@ -340,3 +330,79 @@ impl Game<'_> {
|
||||
self.ambience.tick(&ctx, &mut self.audio);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve each live entity once per sound tick, even when several sounds name it.
|
||||
fn update_bound_entities(
|
||||
entities: Vec<horizon_client::sound_engine::SoundEntity>,
|
||||
mut resolve: impl FnMut(u32) -> Option<horizon_client::sound_engine::SoundEntityState>,
|
||||
) {
|
||||
use horizon_client::sound_engine::SoundEntityState;
|
||||
let mut states = std::collections::HashMap::new();
|
||||
for entity in entities {
|
||||
let previous = entity.state();
|
||||
if previous.removed {
|
||||
continue;
|
||||
}
|
||||
let state = *states
|
||||
.entry(entity.id)
|
||||
.or_insert_with(|| resolve(entity.id));
|
||||
entity.update(state.unwrap_or(SoundEntityState {
|
||||
removed: true,
|
||||
..previous
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bound_lookup_tests {
|
||||
use super::update_bound_entities;
|
||||
use horizon_client::sound_engine::{SoundEntity, SoundEntityState};
|
||||
|
||||
#[test]
|
||||
fn simultaneous_sounds_share_a_lookup_but_follow_each_new_tick() {
|
||||
let a = SoundEntity::new(7, [0.0; 3], false);
|
||||
let b = SoundEntity::new(7, [1.0; 3], false);
|
||||
let mut calls = Vec::new();
|
||||
for position in [[24.0, 80.0, -16.0], [25.5, 81.0, -18.0]] {
|
||||
update_bound_entities(vec![a.clone(), b.clone()], |id| {
|
||||
calls.push(id);
|
||||
Some(SoundEntityState {
|
||||
position,
|
||||
silent: true,
|
||||
removed: false,
|
||||
})
|
||||
});
|
||||
assert_eq!(a.state().position, position);
|
||||
assert_eq!(b.state().position, position);
|
||||
assert!(a.state().silent && b.state().silent);
|
||||
}
|
||||
assert_eq!(calls, vec![7, 7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_entities_stop_all_their_sounds_without_reviving_reused_ids() {
|
||||
let a = SoundEntity::new(7, [1.0; 3], false);
|
||||
let b = SoundEntity::new(7, [2.0; 3], false);
|
||||
let mut calls = 0;
|
||||
update_bound_entities(vec![a.clone(), b.clone()], |_| {
|
||||
calls += 1;
|
||||
None
|
||||
});
|
||||
assert_eq!(calls, 1);
|
||||
assert!(a.state().removed && b.state().removed);
|
||||
let replacement = SoundEntity::new(7, [3.0; 3], false);
|
||||
update_bound_entities(vec![a.clone(), replacement.clone(), b.clone()], |_| {
|
||||
calls += 1;
|
||||
Some(SoundEntityState {
|
||||
position: [4.0; 3],
|
||||
silent: false,
|
||||
removed: false,
|
||||
})
|
||||
});
|
||||
assert_eq!(calls, 2);
|
||||
assert!(a.state().removed && b.state().removed);
|
||||
assert_eq!(a.state().position, [1.0; 3]);
|
||||
assert_eq!(replacement.state().position, [4.0; 3]);
|
||||
assert!(!replacement.state().removed);
|
||||
}
|
||||
}
|
||||
@@ -248,9 +248,12 @@ impl BlockEntityDraw {
|
||||
*pose *= m;
|
||||
}
|
||||
}
|
||||
fn pose(&self) -> Mat4 {
|
||||
pub(super) fn pose(&self) -> Mat4 {
|
||||
self.poses.last().copied().unwrap_or(Mat4::IDENTITY)
|
||||
}
|
||||
pub(super) fn vertices_mut(&mut self) -> &mut Vec<Vertex> {
|
||||
&mut self.vertices
|
||||
}
|
||||
/// A quad of `corners` (blocks, under the pose) with unit UVs, shaded as
|
||||
/// vanilla's block entity models are (`Lighting`'s two lights).
|
||||
fn quad(&mut self, corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], sprite: Rect, tint: u32) {
|
||||
|
||||
@@ -18,13 +18,17 @@ impl Game<'_> {
|
||||
/// stays open with nobody there. Tickets are kept until the owner lets go
|
||||
/// or the server stops, not saved: keep what you hold (a level
|
||||
/// attachment) and hold it again in `native_level_load`. True when the
|
||||
/// set of owners changed; false for an owner without a namespace and off
|
||||
/// a server. API @API:chunk_tickets@.
|
||||
/// set of owners changed; false for an owner without a namespace, a chunk
|
||||
/// past the world's edge (as `/forceload` refuses it) and off a server. API @API:chunk_tickets@.
|
||||
pub(super) fn native_force_chunk(&mut self, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
|
||||
let namespaced = owner
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
|
||||
if !namespaced || !self.native_authoritative() {
|
||||
// `/forceload`'s world: blocks -30,000,000 to 29,999,999, so a mod's
|
||||
// corrupt saved list asks for nothing past the edge (where the ring's
|
||||
// arithmetic would overflow).
|
||||
let in_world = chunk.iter().all(|c| (-1_875_000..1_875_000).contains(c));
|
||||
if !namespaced || !self.native_authoritative() || (hold && !in_world) {
|
||||
return false;
|
||||
}
|
||||
let key = (chunk[0], chunk[1]);
|
||||
|
||||
@@ -392,4 +392,94 @@ mod client_tests {
|
||||
assert_eq!(game.native_entity_position(stand), None);
|
||||
assert_eq!(game.native_entity_kind(display), None);
|
||||
}
|
||||
|
||||
/// #8589: the server sends entity event 42 (the raid sweat) for a villager;
|
||||
/// the client must draw the particles, as it does for the events it let
|
||||
/// through before. An event with no particle table row (2) draws nothing.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_villager_raid_sweat_event_draws_particles() {
|
||||
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();
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (pipe, _server) = horizon_protocol::connection::Pipe::pair();
|
||||
game.connect_local(pipe, "Steve").unwrap();
|
||||
let builtin = game.remote.as_ref().unwrap().builtin.clone();
|
||||
let kind = builtin
|
||||
.get("minecraft:entity_type")
|
||||
.unwrap()
|
||||
.id("minecraft:villager")
|
||||
.unwrap();
|
||||
game.handle_packet(C::Login(play::Login {
|
||||
player_id: 7,
|
||||
hardcore: false,
|
||||
levels: vec!["minecraft:overworld".into()],
|
||||
max_players: 20,
|
||||
chunk_radius: 8,
|
||||
simulation_distance: 8,
|
||||
reduced_debug_info: false,
|
||||
show_death_screen: true,
|
||||
limited_crafting: false,
|
||||
spawn: spawn("minecraft:overworld"),
|
||||
online_mode: false,
|
||||
enforces_secure_chat: false,
|
||||
}))
|
||||
.unwrap();
|
||||
game.handle_packet(C::PlayerPosition(play::PlayerPosition {
|
||||
teleport: 1,
|
||||
position: [0.5, 65.0, 0.5],
|
||||
delta: [0.0; 3],
|
||||
yaw: 45.0,
|
||||
pitch: 10.0,
|
||||
relatives: 0,
|
||||
}))
|
||||
.unwrap();
|
||||
game.handle_packet(C::AddEntity(play::AddEntity {
|
||||
id: 21,
|
||||
uuid: 0x0011_2233_4455_6677_8899_aabb_ccdd_eeff,
|
||||
kind,
|
||||
// Within 32 blocks of the camera, which starts at the origin: the
|
||||
// particle engine drops a particle farther away than that.
|
||||
position: [1.5, 1.0, 2.5],
|
||||
velocity: [0.0; 3],
|
||||
pitch: 0,
|
||||
yaw: 64,
|
||||
head_yaw: 64,
|
||||
data: 0,
|
||||
}))
|
||||
.unwrap();
|
||||
// `len` counts only particles already ticked in; `made` counts every
|
||||
// particle created, which is what the event handler does at once.
|
||||
let made = |g: &Game| g.particles.made().values().sum::<u64>();
|
||||
let before = made(&game);
|
||||
game.handle_packet(C::EntityEvent(play::EntityEvent { id: 21, event: 2 }))
|
||||
.unwrap();
|
||||
assert_eq!(made(&game), before, "event 2 has no particle row");
|
||||
game.handle_packet(C::EntityEvent(play::EntityEvent { id: 21, event: 42 }))
|
||||
.unwrap();
|
||||
assert!(made(&game) > before, "event 42 draws the sweat");
|
||||
}
|
||||
}
|
||||
@@ -988,6 +988,7 @@ use super::{
|
||||
native_mob_draw::{NativeMobRender, NativePlayerRender},
|
||||
native_panel_recipes::RecipePage,
|
||||
native_reload_listeners::NativeReloadListener,
|
||||
native_world_draw::NativeWorldDraw,
|
||||
};
|
||||
use horizon_assets::item_model::StackView;
|
||||
use horizon_client::mod_api::{
|
||||
@@ -1015,6 +1016,7 @@ use horizon_client::{
|
||||
first_person::Hand,
|
||||
health::{HurtSource, Hurting, Knock},
|
||||
hud::{HudBatch, rgba},
|
||||
hud_layout::HealthHud,
|
||||
items::ItemStack,
|
||||
keybinds,
|
||||
lan::menu::Slots,
|
||||
@@ -3304,6 +3306,14 @@ impl Game<'_> {
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
/// Submit depth-tested world text and textured quads, after particles and
|
||||
/// before the hand/HUD. The pose starts camera-relative; use
|
||||
/// `draw.translate_world` for a world position. Called independently of
|
||||
/// block entities and chunks, so transient overlays may outlive their
|
||||
/// source block. API @API:world_canvas@.
|
||||
pub(super) fn native_world_submit(&self, draw: &mut NativeWorldDraw) {
|
||||
mod_api::dispatch_world_submit(self, mod_api::mods(), draw);
|
||||
}
|
||||
/// Draw the mob `mob` (its id, kind, custom name and the frame's body
|
||||
/// and head turns and walk cycle, `LivingEntityRenderState`) on this
|
||||
/// client with a model of your own, once a frame while it is in view
|
||||
@@ -9100,6 +9110,24 @@ impl Game<'_> {
|
||||
let screen = self.shown_native_screen()?;
|
||||
Some(screen.extra.as_ref().map_or(&[], |e| e.data.as_slice()))
|
||||
}
|
||||
/// Horizon's own health HUD state, for a mod answering
|
||||
/// `native_health_hud`. `left_height`/`right_height` as they stand when
|
||||
/// the hook returns place the selected item's name and the action bar
|
||||
/// (vanilla's `Hud.leftHeight`/`rightHeight`): set them when you draw
|
||||
/// more or fewer rows. `layout` draws Horizon's rows and advances their
|
||||
/// frame state (the hearts' blink, the bubble pop sound), so call it at
|
||||
/// most once a frame, and only when you keep some of its rows.
|
||||
/// API @API:health_hud_state@.
|
||||
pub(super) fn native_health_hud_state(&mut self) -> &mut HealthHud {
|
||||
&mut self.health_hud
|
||||
}
|
||||
/// The current value, base plus modifiers, of this client's player's
|
||||
/// attribute `name` (`minecraft:armor_toughness`), as vanilla's
|
||||
/// `LivingEntity.getAttributeValue`. None with no player, or for an id
|
||||
/// the player has no attribute for. API @API:health_hud_state@.
|
||||
pub(super) fn native_local_attribute(&mut self, name: &str) -> Option<f64> {
|
||||
self.player.as_mut()?.attribute(name).ok()
|
||||
}
|
||||
/// A data file of the game's resources, content mods' included
|
||||
/// (`data/mymod/machines.json`), when there is one: what a mod's content
|
||||
/// says, read at runtime. Only relative paths of plain names. API 20.
|
||||
@@ -9244,10 +9272,11 @@ impl Game<'_> {
|
||||
pub(super) fn native_chat(&mut self, text: &str) {
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let line = Text::literal(text);
|
||||
let ticks = self.visual_ticks();
|
||||
let atlas = &self.gui.atlas;
|
||||
self.ui
|
||||
.chat
|
||||
.add(atlas, line, None, ChatSource::Client, self.ticks, focused);
|
||||
.add(atlas, line, None, ChatSource::Client, ticks, focused);
|
||||
}
|
||||
/// The instance (game) folder, which its players share: `saves/`,
|
||||
/// `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are in it, and the
|
||||
|
||||
@@ -97,7 +97,9 @@ impl Game<'_> {
|
||||
/// which a player has none of. API @API:host_bridge@.
|
||||
pub(super) fn native_equipment(&self, slot: EquipmentSlot) -> Option<ItemStack> {
|
||||
match slot {
|
||||
EquipmentSlot::MainHand => self.held().cloned(),
|
||||
// The selected slot, not `held`: that is the acting hand, the off hand's stack during
|
||||
// the off-hand pass of a use (`with_hand(Hand::Off, ..)`).
|
||||
EquipmentSlot::MainHand => self.inventory.get(self.selected).cloned().flatten(),
|
||||
EquipmentSlot::OffHand => self.offhand.clone(),
|
||||
EquipmentSlot::Head => self.armor[0].clone(),
|
||||
EquipmentSlot::Chest => self.armor[1].clone(),
|
||||
@@ -118,3 +120,64 @@ impl Game<'_> {
|
||||
self.keys.contains(&KeyCode::ShiftLeft)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{first_person::Hand, items::ItemStack};
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
|
||||
/// `LivingEntity.getItemBySlot` doesn't depend on the hand acting: in the
|
||||
/// off-hand pass of a use, the main hand is still the selected slot's.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn equipment_reads_the_main_hand_in_an_off_hand_pass() {
|
||||
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();
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let name = |stack: Option<ItemStack>| stack.map(|s| s.item.to_string());
|
||||
game.inventory[game.selected] = Some(ItemStack::new("minecraft:stick", 1));
|
||||
game.offhand = Some(ItemStack::new("minecraft:torch", 1));
|
||||
for hand in [Hand::Main, Hand::Off] {
|
||||
game.with_hand(hand, |g| {
|
||||
assert_eq!(
|
||||
name(g.native_equipment(EquipmentSlot::MainHand)).as_deref(),
|
||||
Some("minecraft:stick"),
|
||||
"{hand:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
name(g.native_equipment(EquipmentSlot::OffHand)).as_deref(),
|
||||
Some("minecraft:torch"),
|
||||
"{hand:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
game.inventory[game.selected] = None;
|
||||
game.with_hand(Hand::Off, |g| {
|
||||
assert!(g.native_equipment(EquipmentSlot::MainHand).is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ impl Game<'_> {
|
||||
/// (`Block.dropResources(state, level, pos, blockEntity, null, tool)`):
|
||||
/// the break particles and sound, the block's drops with what its block
|
||||
/// entity copies into them (a shulker box's contents), experience for an
|
||||
/// ore as with no tool, then the block's fluid in its place. `tool` picks
|
||||
/// ore unless `tool` has silk touch (`EnchantmentHelper.processBlockExperience`),
|
||||
/// then the block's fluid in its place. Blocks that pop off because of
|
||||
/// the break drop as with no tool, and pop whatever `collect` says. `tool` picks
|
||||
/// the drops as a player's held item does (silk touch, fortune, shears);
|
||||
/// `None` is an empty hand. The mods' say on drops (`native_loot_drops`)
|
||||
/// applies. With `collect`, the drops are returned and nothing is popped
|
||||
@@ -47,7 +49,7 @@ impl Game<'_> {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let tool_kept = tool.cloned();
|
||||
let mut drops = self.with_level(|b, level, ticks| {
|
||||
level.drop_tool = tool_kept;
|
||||
level.drop_tool = Some(((pos[0], pos[1], pos[2]), tool_kept));
|
||||
level.drop_into = Some(Vec::new());
|
||||
b.destroy_block(level, ticks, at, true);
|
||||
level.drop_tool = None;
|
||||
|
||||
@@ -305,6 +305,7 @@ impl Game<'_> {
|
||||
let (back, label) = self.back_button(layout);
|
||||
let atlas = &self.gui.atlas;
|
||||
let hover = inside(cursor, back);
|
||||
horizon_client::widgets::declare_action(horizon_client::widgets::Action::Back, back, true);
|
||||
let sprite = if hover {
|
||||
"gui/sprites/widget/button_highlighted"
|
||||
} else {
|
||||
|
||||
@@ -21,6 +21,10 @@ struct SceneQuad {
|
||||
uv: [[f32; 2]; 4],
|
||||
color: u32,
|
||||
normal: Vec3,
|
||||
/// Dropped when it faces away: block model faces, and the quads a block
|
||||
/// entity marks for culling as `entity.wgsl` does (`CULL_BACK`,
|
||||
/// `SHADED_CULL_W`). Other quads draw both sides, as in the world.
|
||||
cull: bool,
|
||||
}
|
||||
|
||||
/// The GUI transform of `view` in the box `rect` (x0, y0, x1, y1): the view's
|
||||
@@ -82,8 +86,14 @@ impl Game<'_> {
|
||||
if !self.native_window() || !view.scale.is_finite() || view.scale <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let occupied: std::collections::HashMap<[i32; 3], _> =
|
||||
blocks.iter().map(|b| (b.pos, b.state)).collect();
|
||||
// A state the registry doesn't know (a mod's stale id) culls nothing
|
||||
// instead of panicking in the block facts.
|
||||
let registry = &self.pack.assets.registry;
|
||||
let occupied: std::collections::HashMap<[i32; 3], _> = blocks
|
||||
.iter()
|
||||
.filter(|b| registry.state(b.state).is_some())
|
||||
.map(|b| (b.pos, b.state))
|
||||
.collect();
|
||||
let solid = |pos: [i32; 3]| {
|
||||
occupied.get(&pos).is_some_and(|&state| {
|
||||
self.res.behaviours().facts(state).flags & state_flags::SOLID_RENDER != 0
|
||||
@@ -140,6 +150,7 @@ impl Game<'_> {
|
||||
}),
|
||||
color,
|
||||
normal,
|
||||
cull: true,
|
||||
});
|
||||
}
|
||||
if let Some(entity) = block.entity {
|
||||
@@ -166,14 +177,27 @@ impl Game<'_> {
|
||||
);
|
||||
for q in placed.as_chunks::<4>().0 {
|
||||
let corners = q.each_ref().map(|v| Vec3::from_array(v.pos));
|
||||
let normal = (corners[1] - corners[0])
|
||||
let [nx, ny, nz, w] = q[0].normal;
|
||||
let mut normal = (corners[1] - corners[0])
|
||||
.cross(corners[2] - corners[0])
|
||||
.normalize_or_zero();
|
||||
if normal == Vec3::ZERO {
|
||||
normal = Vec3::new(f32::from(nx), f32::from(ny), f32::from(nz))
|
||||
.normalize_or_zero();
|
||||
}
|
||||
// `entity.wgsl`: |w| >= 0.5 is shaded by the normal; smaller
|
||||
// keeps the colour baked in (quads, cubes, items, text).
|
||||
let unbaked = w.unsigned_abs() >= 64;
|
||||
quads.push(SceneQuad {
|
||||
corners,
|
||||
uv: q.each_ref().map(|v| v.uv),
|
||||
color: shaded(q[0].color, face_shade(normal)),
|
||||
color: if unbaked {
|
||||
shaded(q[0].color, face_shade(normal))
|
||||
} else {
|
||||
q[0].color
|
||||
},
|
||||
normal,
|
||||
cull: (1..64).contains(&w) || (-126..0).contains(&w),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -181,7 +205,7 @@ impl Game<'_> {
|
||||
let m = scene_matrix(rect, view);
|
||||
let mut seen: Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> = quads
|
||||
.into_iter()
|
||||
.filter(|q| m.transform_vector3(q.normal).z > 0.0)
|
||||
.filter(|q| !q.cull || m.transform_vector3(q.normal).z > 0.0)
|
||||
.map(|q| {
|
||||
let p = q.corners.map(|c| m.transform_point3(c));
|
||||
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! A native mod's world-space text and atlas quads, through the normal renderer.
|
||||
use super::{Game, native_block_entity_draw::BlockEntityDraw};
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
use horizon_client::{
|
||||
font::Patch,
|
||||
gfx::{WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY},
|
||||
hud::{HudBatch, Rect, rgba},
|
||||
mesher::Vertex,
|
||||
text::Text,
|
||||
};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
/// One world frame, independently of block entities or source blocks. Its pose
|
||||
/// starts camera-relative and shares `BlockEntityDraw`'s push/pop, translate,
|
||||
/// rotate and scale operations, partial tick, game time and packed light.
|
||||
/// `keep` does not cache a world canvas. API @API:world_canvas@.
|
||||
pub struct NativeWorldDraw {
|
||||
pub camera: [f64; 3],
|
||||
draw: BlockEntityDraw,
|
||||
patches: Vec<Patch>,
|
||||
profiles: Vec<serde_json::Value>,
|
||||
}
|
||||
impl Deref for NativeWorldDraw {
|
||||
type Target = BlockEntityDraw;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.draw
|
||||
}
|
||||
}
|
||||
impl DerefMut for NativeWorldDraw {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.draw
|
||||
}
|
||||
}
|
||||
impl NativeWorldDraw {
|
||||
fn new(camera: [f64; 3], partial: f32, time: i64) -> Self {
|
||||
Self {
|
||||
camera,
|
||||
draw: BlockEntityDraw::posed(
|
||||
partial,
|
||||
time,
|
||||
240 | 240 << 8,
|
||||
[0; 3],
|
||||
Mat4::IDENTITY,
|
||||
(Vec3::Y, Vec3::Y),
|
||||
Vec::new(),
|
||||
),
|
||||
patches: Vec::new(),
|
||||
profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Translate to a world position, subtracting the camera in f64 first.
|
||||
/// Returns false without changing the pose for a non-finite offset.
|
||||
pub fn translate_world(&mut self, position: [f64; 3]) -> bool {
|
||||
let offset = (DVec3::from_array(position) - DVec3::from_array(self.camera)).as_vec3();
|
||||
if !offset.is_finite() {
|
||||
return false;
|
||||
}
|
||||
self.draw.translate(offset.to_array());
|
||||
true
|
||||
}
|
||||
fn quad(&mut self, sprite: Rect, corners: [[f32; 3]; 4], uv: [f32; 4], color: [u8; 4]) -> bool {
|
||||
let pose = self.draw.pose();
|
||||
if !pose.is_finite()
|
||||
|| !corners.iter().flatten().all(|v| v.is_finite())
|
||||
|| !uv.iter().all(|v| v.is_finite())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let [u0, v0, u1, v1] = uv.map(|v| v / 16.0);
|
||||
let points = corners.map(|point| pose.transform_point3(Vec3::from_array(point)));
|
||||
if points.iter().any(|point| !point.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let light = self.draw.light;
|
||||
for (point, [u, v]) in points
|
||||
.into_iter()
|
||||
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
|
||||
{
|
||||
self.draw.vertices_mut().push(Vertex {
|
||||
pos: point.to_array(),
|
||||
uv: [
|
||||
sprite.u0 + (sprite.u1 - sprite.u0) * u,
|
||||
sprite.v0 + (sprite.v1 - sprite.v0) * v,
|
||||
],
|
||||
layer: WORLD_TEXT_OVERLAY,
|
||||
color: u32::from_le_bytes(color),
|
||||
light,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
fn text(&mut self, batch: HudBatch, front: Option<usize>) {
|
||||
let pose = self.draw.pose();
|
||||
let light = self.draw.light;
|
||||
if pose.is_finite() {
|
||||
for (index, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() {
|
||||
for i in horizon_client::text_display::CORNERS {
|
||||
let vertex = quad[i];
|
||||
self.draw.vertices_mut().push(Vertex {
|
||||
pos: pose
|
||||
.transform_point3(Vec3::new(vertex.pos[0], vertex.pos[1], vertex.depth))
|
||||
.to_array(),
|
||||
uv: vertex.uv,
|
||||
color: vertex.color,
|
||||
light,
|
||||
layer: if front.is_some_and(|start| index * 6 >= start) {
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY
|
||||
} else {
|
||||
WORLD_TEXT_OVERLAY
|
||||
},
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.patches.extend(batch.patches);
|
||||
self.profiles.extend(batch.inline_profiles);
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn native_world_geometry(
|
||||
&mut self,
|
||||
camera: DVec3,
|
||||
partial: f32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !horizon_client::gfx::native_render::world_drawn_hooked()
|
||||
&& crate::mod_api::mods().is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut draw = NativeWorldDraw::new(camera.to_array(), partial, self.world.game_time);
|
||||
// NONE keeps these quads out of shader-pack entity programs.
|
||||
self.mark_stage(out.len(), "NONE");
|
||||
self.native_world_submit(&mut draw);
|
||||
self.resolve_inline_profiles(&draw.profiles);
|
||||
self.map_patches.extend(draw.patches);
|
||||
out.extend(draw.draw.into_vertices());
|
||||
}
|
||||
/// Normal, depth-tested atlas quad under the current pose. UVs use 16
|
||||
/// texels per sprite side; color is [red, green, blue, alpha]. No model
|
||||
/// diffuse lighting is applied; `draw.light` still selects lightmap light.
|
||||
/// False for an unknown sprite or non-finite geometry. API @API:world_canvas@.
|
||||
pub(super) fn native_world_quad(
|
||||
&self,
|
||||
draw: &mut NativeWorldDraw,
|
||||
sprite: &str,
|
||||
corners: [[f32; 3]; 4],
|
||||
uv: [f32; 4],
|
||||
color: [u8; 4],
|
||||
) -> bool {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(sprite)
|
||||
.copied()
|
||||
.is_some_and(|sprite| draw.quad(sprite, corners, uv, color))
|
||||
}
|
||||
/// Actual styled world font at (0,0), with ARGB default color and optional
|
||||
/// eight-offset ARGB outline. Returns the text advance in font pixels.
|
||||
/// The front of outlined text uses POLYGON_OFFSET. API @API:world_canvas@.
|
||||
pub(super) fn native_world_text(
|
||||
&self,
|
||||
draw: &mut NativeWorldDraw,
|
||||
text: &Text,
|
||||
color: u32,
|
||||
outline: Option<u32>,
|
||||
) -> f32 {
|
||||
let packed = |c: u32| rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, (c >> 24) as u8);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let (advance, front) = if let Some(outline) = outline {
|
||||
let (advance, front) = batch.text_styled_outline_world(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
0.0,
|
||||
0.0,
|
||||
packed(color),
|
||||
packed(outline),
|
||||
);
|
||||
(advance, Some(front))
|
||||
} else {
|
||||
(
|
||||
batch.text_styled_world(&self.gui.atlas, text, 0.0, 0.0, packed(color), false),
|
||||
None,
|
||||
)
|
||||
};
|
||||
draw.text(batch, front);
|
||||
advance
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn world_quad_preserves_sub_block_precision_rgba_uv_and_pose() {
|
||||
let mut frame = NativeWorldDraw::new([30_000_000.125, 64.0, -30_000_000.5], 0.5, 123);
|
||||
assert!(frame.translate_world([30_000_000.625, 65.0, -30_000_000.25]));
|
||||
frame.scale([2.0, 3.0, 4.0]);
|
||||
frame.light = 0x12_34;
|
||||
assert!(frame.quad(
|
||||
Rect {
|
||||
w: 16,
|
||||
h: 16,
|
||||
u0: 0.25,
|
||||
v0: 0.5,
|
||||
u1: 0.75,
|
||||
v1: 1.0
|
||||
},
|
||||
[
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[1.0, 0.0, 0.0]
|
||||
],
|
||||
[4.0, 0.0, 12.0, 8.0],
|
||||
[12, 34, 56, 78]
|
||||
));
|
||||
let vertices = frame.draw.into_vertices();
|
||||
assert_eq!(
|
||||
vertices.iter().map(|v| v.pos).collect::<Vec<_>>(),
|
||||
[
|
||||
[0.5, 1.0, 0.25],
|
||||
[0.5, 4.0, 0.25],
|
||||
[2.5, 4.0, 0.25],
|
||||
[2.5, 1.0, 0.25]
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
vertices.iter().map(|v| v.uv).collect::<Vec<_>>(),
|
||||
[[0.375, 0.5], [0.375, 0.75], [0.625, 0.75], [0.625, 0.5]]
|
||||
);
|
||||
assert!(
|
||||
vertices
|
||||
.iter()
|
||||
.all(|v| v.color.to_le_bytes() == [12, 34, 56, 78]
|
||||
&& v.light == 0x12_34
|
||||
&& v.normal == [0; 4])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn invalid_world_offsets_leave_the_pose_usable() {
|
||||
let mut frame = NativeWorldDraw::new([0.0; 3], 0.0, 0);
|
||||
assert!(!frame.translate_world([f64::NAN, 0.0, 0.0]));
|
||||
assert!(!frame.translate_world([f64::MAX, 0.0, 0.0]));
|
||||
assert!(frame.translate_world([1.0, 2.0, 3.0]));
|
||||
assert_eq!(
|
||||
frame.draw.pose().transform_point3(Vec3::ZERO),
|
||||
Vec3::new(1.0, 2.0, 3.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -501,6 +501,8 @@ impl Game<'_> {
|
||||
s.horizon.frame_generation != horizon_client::graphics::FrameGeneration::Off,
|
||||
);
|
||||
r.set_hdr_lighting(s.horizon.hdr_lighting);
|
||||
// Where it is offered (`Renderer::gpu_culling_offer`); unchanged, it does nothing.
|
||||
let _ = r.set_gpu_culling(s.horizon.gpu_culling);
|
||||
r.set_hdr(
|
||||
s.horizon.hdr,
|
||||
s.horizon.paper_white_nits() as f32,
|
||||
|
||||
@@ -28,6 +28,8 @@ pub(super) const HELP: &[&str] = &[
|
||||
pub(super) struct Pads {
|
||||
/// Splitscreen: this player's pad, read by `PadRouter` for every game, instead of the pads.
|
||||
pub(super) feed: Option<PadState>,
|
||||
/// The pad behind `feed`.
|
||||
feed_source: Option<PadId>,
|
||||
/// The real controllers and the DualSense's output.
|
||||
#[cfg(feature = "controllers")]
|
||||
devices: hardware::Devices,
|
||||
@@ -70,6 +72,7 @@ struct Context {
|
||||
focus_navigation: bool,
|
||||
focused: bool,
|
||||
osk: bool,
|
||||
dialog: bool,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
@@ -162,8 +165,18 @@ impl Pads {
|
||||
pub(super) fn inject_button(&mut self, button: Button, down: bool) {
|
||||
self.injected.set(button, down);
|
||||
}
|
||||
/// Splitscreen: this player's pad state, from `source`. Another pad, or
|
||||
/// none: the old one's A going away isn't a release.
|
||||
pub(super) fn feed_from(&mut self, source: Option<PadId>, state: PadState) {
|
||||
if self.feed_source != source {
|
||||
self.feed_source = source;
|
||||
self.menu.armed = None;
|
||||
}
|
||||
self.feed = Some(state);
|
||||
}
|
||||
/// A replay ended: let go of everything it held.
|
||||
pub(super) fn clear_injected(&mut self) {
|
||||
self.menu.armed = None;
|
||||
self.injected = PadState::default();
|
||||
}
|
||||
|
||||
@@ -193,6 +206,7 @@ impl Game<'_> {
|
||||
focus_navigation: self.pad_navigation_mode(),
|
||||
focused: self.focused,
|
||||
osk: self.pad.menu.osk.is_some(),
|
||||
dialog: self.dialog_open() || self.code_of_conduct.is_some(),
|
||||
enabled: self.settings.controller.enabled,
|
||||
}
|
||||
}
|
||||
@@ -323,6 +337,8 @@ impl Game<'_> {
|
||||
|
||||
/// End held actions and captures before another context receives input.
|
||||
fn pad_cancel_inputs(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
// Before the releases below: a cancelled press must not click.
|
||||
self.pad.menu.armed = None;
|
||||
let held_sticks = [
|
||||
gamepad::radial(self.pad.state.left, self.settings.controller.move_deadzone)
|
||||
!= [0.0; 2],
|
||||
@@ -578,6 +594,7 @@ impl Game<'_> {
|
||||
Ok(status)
|
||||
}
|
||||
"release" => {
|
||||
self.pad.menu.armed = None;
|
||||
self.pad.injected = PadState::default();
|
||||
self.pad.taps.clear();
|
||||
Ok(json!({ "ok": true }))
|
||||
|
||||
@@ -114,6 +114,7 @@ impl Pads {
|
||||
let Some(gilrs) = self.devices.gilrs.as_mut() else {
|
||||
return PadState::default();
|
||||
};
|
||||
let active = self.devices.active;
|
||||
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
|
||||
use gilrs::EventType as E;
|
||||
match event {
|
||||
@@ -141,6 +142,10 @@ impl Pads {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Another pad, or none: its A going away isn't a release.
|
||||
if self.devices.active != active {
|
||||
self.menu.armed = None;
|
||||
}
|
||||
let Some(pad) = self
|
||||
.devices
|
||||
.active
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! The controller's side of the buttons screens declare (`widgets::Action`): which pad button does
|
||||
//! which job, and the glyph on the button while the pad drives. A screen only declares; nothing
|
||||
//! here names one (docs/controllers.md, "Adding a screen").
|
||||
use super::{Game, Screen};
|
||||
use horizon_client::{
|
||||
gamepad::{Button, GlyphStyle},
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
widgets::{self, Action, DeclaredAction, Kind, Target},
|
||||
};
|
||||
|
||||
/// The pad buttons that can do a declared job.
|
||||
const ACTION_BUTTONS: [Button; 3] = [Button::West, Button::East, Button::North];
|
||||
|
||||
/// The declared button `button` does on this screen, if any.
|
||||
fn action_for_button(button: Button, declared: &[DeclaredAction]) -> Option<DeclaredAction> {
|
||||
let find = |action| {
|
||||
declared
|
||||
.iter()
|
||||
.find(|d| d.action == action && d.enabled)
|
||||
.copied()
|
||||
};
|
||||
let leave = || {
|
||||
find(Action::Back)
|
||||
.or_else(|| find(Action::Cancel))
|
||||
.or_else(|| find(Action::Done))
|
||||
};
|
||||
match button {
|
||||
Button::West => find(Action::Apply).or_else(|| find(Action::Options)),
|
||||
Button::East => leave().or_else(|| find(Action::Exit)),
|
||||
// B is taken by leaving the screen: Y goes to the exit.
|
||||
Button::North => leave().and_then(|_| find(Action::Exit)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a glyph `wide` goes on a button: inside its left end (or right), centred on its height.
|
||||
/// `None` when the button is too small for it or isn't all in the view.
|
||||
fn glyph_at(
|
||||
[x, y, w, h]: [f32; 4],
|
||||
wide: f32,
|
||||
(view_w, view_h): (f32, f32),
|
||||
right: bool,
|
||||
) -> Option<(f32, f32)> {
|
||||
const INSET: f32 = 4.0;
|
||||
let fits = w >= wide + 2.0 * INSET && h >= 11.0;
|
||||
let shown = x >= 0.0 && y >= 0.0 && x + w <= view_w && y + h <= view_h;
|
||||
(fits && shown).then(|| {
|
||||
let at = if right {
|
||||
x + w - INSET - wide + 2.0
|
||||
} else {
|
||||
x + INSET
|
||||
};
|
||||
(at, y + ((h - 8.0) / 2.0).floor())
|
||||
})
|
||||
}
|
||||
|
||||
/// The button as printed on the pad, in its colour, on a dark key.
|
||||
fn draw_glyph(
|
||||
batch: &mut HudBatch,
|
||||
atlas: &HudAtlas,
|
||||
button: Button,
|
||||
style: GlyphStyle,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) {
|
||||
let (label, [r, g, b]) = (button.label(style), button.color(style));
|
||||
let width = HudBatch::text_width(atlas, label).ceil() + 4.0;
|
||||
batch.fill(atlas, x - 1.0, y - 2.0, width, 11.0, rgba(40, 40, 40, 255));
|
||||
batch.text(atlas, label, x + 1.0, y, rgba(r, g, b, 255), false);
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A key row waits for its key: B is Escape, which unbinds; its buttons aren't pressed.
|
||||
fn pad_binding(&self) -> bool {
|
||||
self.screen == Screen::Options
|
||||
&& (self.options_waiting_for_key() || self.ui.options.selected_pad_action.is_some())
|
||||
}
|
||||
|
||||
/// The declared buttons the pad can do now.
|
||||
fn pad_actions(&self) -> Vec<DeclaredAction> {
|
||||
if self.pad_binding() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut declared = widgets::last_actions(self.key_screen());
|
||||
// A split guest's Quit Game does nothing.
|
||||
if self.options.split_guest && self.title.is_some() {
|
||||
declared.retain(|d| d.action != Action::Exit);
|
||||
}
|
||||
declared
|
||||
}
|
||||
|
||||
/// The job `button` does here, on the target that does it. `None` while it isn't on screen,
|
||||
/// so B still falls back to Escape.
|
||||
pub(super) fn pad_action_target(&self, button: Button) -> Option<(Action, Target)> {
|
||||
let declared = action_for_button(button, &self.pad_actions())?;
|
||||
let [x, y, w, h] = declared.rect;
|
||||
let at = (x + w / 2.0, y + h / 2.0);
|
||||
// A button that registers no focus target (a sign's Done) is still pressed.
|
||||
let target = self
|
||||
.pad_targets()
|
||||
.into_iter()
|
||||
.find(|t| {
|
||||
let [x, y, w, h] = t.rect;
|
||||
(x..x + w).contains(&at.0) && (y..y + h).contains(&at.1)
|
||||
})
|
||||
.unwrap_or(Target {
|
||||
rect: declared.rect,
|
||||
kind: Kind::Button,
|
||||
});
|
||||
Some((declared.action, target))
|
||||
}
|
||||
|
||||
/// Declared buttons show their glyph: the pad drives, and a menu is open. Focus or crosshair.
|
||||
pub(super) fn pad_action_glyphs_on(&self) -> bool {
|
||||
self.pad.in_use
|
||||
&& !self.pad.menu.keyboard
|
||||
&& self.pad.menu.osk.is_none()
|
||||
&& self.pad.menu.pos.is_some()
|
||||
&& self.pad_in_menu()
|
||||
}
|
||||
|
||||
/// Each pad button's glyph on the button it does, A on an exit the focus is already on: this
|
||||
/// player's glyphs in this player's view. One that doesn't fit is left out.
|
||||
fn pad_glyphs(&self, view: (f32, f32)) -> Vec<(Button, f32, f32)> {
|
||||
if !self.pad_action_glyphs_on() {
|
||||
return Vec::new();
|
||||
}
|
||||
let focus = self.pad.menu.focus.filter(|_| self.pad_focus_mode());
|
||||
let (atlas, style) = (&self.gui.atlas, self.ui.options.pad_style);
|
||||
let width = |button: Button| HudBatch::text_width(atlas, button.label(style)).ceil() + 4.0;
|
||||
let mut glyphs = Vec::new();
|
||||
// A on a text field opens the keyboard: at its right end, its text stays in view.
|
||||
if let Some(focus) = focus.filter(|f| f.kind == Kind::Field)
|
||||
&& let Some((x, y)) = glyph_at(focus.rect, width(Button::South), view, true)
|
||||
{
|
||||
glyphs.push((Button::South, x, y));
|
||||
}
|
||||
let declared = self.pad_actions();
|
||||
for button in ACTION_BUTTONS {
|
||||
let Some(d) = action_for_button(button, &declared) else {
|
||||
continue;
|
||||
};
|
||||
let glyph = if d.action == Action::Exit && focus.is_some_and(|f| f.rect == d.rect) {
|
||||
Button::South
|
||||
} else {
|
||||
button
|
||||
};
|
||||
if let Some((x, y)) = glyph_at(d.rect, width(glyph), view, false) {
|
||||
glyphs.push((glyph, x, y));
|
||||
}
|
||||
}
|
||||
glyphs
|
||||
}
|
||||
|
||||
pub(super) fn draw_pad_action_glyphs(&self, batch: &mut HudBatch) {
|
||||
let (atlas, style) = (&self.gui.atlas, self.ui.options.pad_style);
|
||||
for (button, x, y) in self.pad_glyphs((batch.width, batch.height)) {
|
||||
draw_glyph(batch, atlas, button, style, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
/// `pad status`: the glyphs drawn now and where.
|
||||
pub(super) fn pad_glyphs_status(&self) -> Vec<serde_json::Value> {
|
||||
let view = self.gui_scale().map_or((0.0, 0.0), |b| (b.width, b.height));
|
||||
self.pad_glyphs(view)
|
||||
.iter()
|
||||
.map(|(button, x, y)| serde_json::json!({ "button": format!("{button:?}"), "at": [x, y] }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `pad status`: what this screen declared.
|
||||
pub(super) fn pad_actions_status(&self) -> Vec<serde_json::Value> {
|
||||
self.pad_actions()
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"action": format!("{:?}", d.action),
|
||||
"rect": d.rect,
|
||||
"enabled": d.enabled,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `pad status`: buttons labelled Done, Back or Cancel that didn't declare their job.
|
||||
pub(super) fn pad_undeclared_buttons(&self) -> Vec<String> {
|
||||
let names = [
|
||||
("gui.done", "Done"),
|
||||
("gui.back", "Back"),
|
||||
("gui.cancel", "Cancel"),
|
||||
]
|
||||
.map(|(key, fallback)| lang::get_or(key, fallback));
|
||||
let declared = widgets::last_actions(self.key_screen());
|
||||
widgets::last_labelled()
|
||||
.into_iter()
|
||||
.filter(|w| names.contains(&w.label))
|
||||
.filter(|w| !declared.iter().any(|d| d.rect == [w.x, w.y, w.w, w.h]))
|
||||
.map(|w| w.label)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn declared(action: Action, enabled: bool) -> DeclaredAction {
|
||||
DeclaredAction {
|
||||
action,
|
||||
rect: [0.0, 0.0, 10.0, 10.0],
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
|
||||
fn does(button: Button, on: &[DeclaredAction]) -> Option<Action> {
|
||||
action_for_button(button, on).map(|d| d.action)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_leaves_the_screen_back_first() {
|
||||
let on = [
|
||||
declared(Action::Done, true),
|
||||
declared(Action::Cancel, true),
|
||||
declared(Action::Back, true),
|
||||
];
|
||||
assert_eq!(does(Button::East, &on), Some(Action::Back));
|
||||
assert_eq!(does(Button::East, &on[..2]), Some(Action::Cancel));
|
||||
assert_eq!(does(Button::East, &on[..1]), Some(Action::Done));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_disabled_button_does_nothing() {
|
||||
let on = [
|
||||
declared(Action::Apply, false),
|
||||
declared(Action::Done, false),
|
||||
];
|
||||
assert_eq!(does(Button::West, &on), None);
|
||||
assert_eq!(does(Button::East, &on), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x_applies_or_opens_the_options() {
|
||||
let on = [
|
||||
declared(Action::Options, true),
|
||||
declared(Action::Apply, true),
|
||||
];
|
||||
assert_eq!(does(Button::West, &on), Some(Action::Apply));
|
||||
assert_eq!(does(Button::West, &on[..1]), Some(Action::Options));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_exit_is_b_with_nothing_to_leave_by_else_y() {
|
||||
let title = [declared(Action::Exit, true)];
|
||||
assert_eq!(does(Button::East, &title), Some(Action::Exit));
|
||||
assert_eq!(does(Button::North, &title), None);
|
||||
let pause = [declared(Action::Back, true), declared(Action::Exit, true)];
|
||||
assert_eq!(does(Button::East, &pause), Some(Action::Back));
|
||||
assert_eq!(does(Button::North, &pause), Some(Action::Exit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glyph_goes_on_a_button_it_fits_in_the_view() {
|
||||
let view = (320.0, 240.0);
|
||||
let button = [10.0, 20.0, 150.0, 20.0];
|
||||
assert_eq!(glyph_at(button, 10.0, view, false), Some((14.0, 26.0)));
|
||||
// A text field's is at its right end.
|
||||
assert_eq!(glyph_at(button, 10.0, view, true), Some((148.0, 26.0)));
|
||||
// Too narrow, too short, running off a small split view.
|
||||
assert_eq!(glyph_at([10.0, 20.0, 15.0, 20.0], 10.0, view, false), None);
|
||||
assert_eq!(glyph_at([10.0, 20.0, 150.0, 8.0], 10.0, view, false), None);
|
||||
assert_eq!(glyph_at(button, 10.0, (120.0, 240.0), false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_screen_that_declares_nothing_keeps_its_buttons() {
|
||||
for button in ACTION_BUTTONS {
|
||||
assert_eq!(does(button, &[]), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ struct Pending {
|
||||
pub(super) struct MenuCursor {
|
||||
/// Where the pad put the pointer (GUI pixels); `None` while the mouse
|
||||
/// has it.
|
||||
pos: Option<(f32, f32)>,
|
||||
pub(super) pos: Option<(f32, f32)>,
|
||||
last_poll: Option<Instant>,
|
||||
/// The stick moved the cursor last poll (it settles when let go).
|
||||
moving: bool,
|
||||
@@ -73,12 +73,15 @@ pub(super) struct MenuCursor {
|
||||
repeat: Option<(Button, Instant, bool)>,
|
||||
/// Pointer buttons the pad holds down.
|
||||
held: [bool; 2],
|
||||
/// The focused button A went down on: letting go presses it, unless the
|
||||
/// focus moved or the input was cancelled first.
|
||||
pub(super) armed: Option<Rect>,
|
||||
/// The OS pointer is hidden while the pad's cursor is drawn.
|
||||
os_hidden: bool,
|
||||
/// Polls since a menu opened (its widgets are drawn after one frame).
|
||||
open_polls: u32,
|
||||
/// Focus mode: the focused target as last drawn.
|
||||
focus: Option<Target>,
|
||||
pub(super) focus: Option<Target>,
|
||||
/// The left stick's step direction last poll.
|
||||
stick: Option<Button>,
|
||||
pending: Option<Pending>,
|
||||
@@ -92,12 +95,14 @@ pub(super) struct MenuCursor {
|
||||
osk_dismissed: bool,
|
||||
/// The keyboard drives the focus (`key_focus`: Tab, the arrows, Enter):
|
||||
/// focus mode whatever the controller options say, the OS pointer shown.
|
||||
keyboard: bool,
|
||||
pub(super) keyboard: bool,
|
||||
/// The focused slider's arrows change its value (`canChangeValue`).
|
||||
slider_edit: bool,
|
||||
/// What the last click landed on: vanilla focuses it, so the next Tab
|
||||
/// or arrow goes on from there (`ContainerEventHandler.mouseClicked`).
|
||||
origin: Option<Rect>,
|
||||
/// A shortcut put the focus here: not where the screen reopens.
|
||||
shortcut_focus: Option<Rect>,
|
||||
/// A pointer event the pad or the keyboard sends is being handled.
|
||||
synthetic: bool,
|
||||
/// Where the real mouse last moved the pointer (window pixels).
|
||||
@@ -120,9 +125,17 @@ impl MenuCursor {
|
||||
self.origin = None;
|
||||
self.slider_edit = false;
|
||||
self.pending = None;
|
||||
self.armed = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Where letting go of A clicks: the armed button, if the focus is still on it.
|
||||
fn armed_click(armed: Option<Rect>, focus: Option<Target>) -> Option<(f32, f32)> {
|
||||
focus
|
||||
.filter(|focus| armed == Some(focus.rect))
|
||||
.map(|focus| focus.center())
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A menu, a screen, the title, a sign or a book is open: the pad drives
|
||||
/// it instead of the player.
|
||||
@@ -152,7 +165,7 @@ impl Game<'_> {
|
||||
!pointer
|
||||
}
|
||||
|
||||
fn pad_focus_mode(&self) -> bool {
|
||||
pub(super) fn pad_focus_mode(&self) -> bool {
|
||||
self.pad.menu.keyboard || self.pad_navigation_mode()
|
||||
}
|
||||
|
||||
@@ -160,7 +173,7 @@ impl Game<'_> {
|
||||
/// target (sliders, fields, rows, keyboard keys), and the slots
|
||||
/// and tabs of inventories (GUI pixels), on screen. The keyboard's
|
||||
/// focus skips what vanilla's keyboard can't reach (`key_targets`).
|
||||
fn pad_targets(&self) -> Vec<Target> {
|
||||
pub(super) fn pad_targets(&self) -> Vec<Target> {
|
||||
if self.pad.menu.keyboard {
|
||||
self.key_targets()
|
||||
} else {
|
||||
@@ -401,6 +414,8 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn pad_wheel(&mut self, event_loop: &dyn EventLoopCtl, x: f32, y: f32) {
|
||||
// Another row can scroll under the armed button's rect.
|
||||
self.pad.menu.armed = None;
|
||||
let Some(id) = self.window.as_ref().map(|w| w.id()) else {
|
||||
return;
|
||||
};
|
||||
@@ -463,6 +478,12 @@ impl Game<'_> {
|
||||
) -> Result<()> {
|
||||
if !down {
|
||||
match button {
|
||||
Button::South if self.pad.menu.armed.is_some() => {
|
||||
let armed = self.pad.menu.armed.take();
|
||||
if let Some(at) = armed_click(armed, self.pad.menu.focus) {
|
||||
self.pad_click_at(event_loop, at, false);
|
||||
}
|
||||
}
|
||||
Button::South if self.pad.menu.held[0] => {
|
||||
self.pad_pointer_button(event_loop, false, false)
|
||||
}
|
||||
@@ -519,6 +540,24 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((action, target)) = self.pad_action_target(button) {
|
||||
if action == widgets::Action::Exit {
|
||||
self.pad_focus_on(event_loop, target);
|
||||
self.pad.menu.shortcut_focus = Some(target.rect);
|
||||
} else {
|
||||
self.pad_click_at(event_loop, target.center(), false);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// A with nothing focused lands the focus, as the D-pad does: nothing
|
||||
// fires on a widget the player hasn't seen highlighted.
|
||||
if button == Button::South && self.pad_focus_mode() && self.pad.menu.focus.is_none() {
|
||||
let targets = self.pad_targets();
|
||||
if let Some(first) = self.pad_first_target(&targets) {
|
||||
self.pad_focus_on(event_loop, first);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
match button {
|
||||
Button::East | Button::Start => return self.pad_back(),
|
||||
Button::South if self.pad_focusing() => {
|
||||
@@ -530,6 +569,10 @@ impl Game<'_> {
|
||||
self.pad_click_at(event_loop, focus.center(), false);
|
||||
self.pad_open_keyboard();
|
||||
}
|
||||
Kind::Button => {
|
||||
self.pad_pointer_to(event_loop, focus.center());
|
||||
self.pad.menu.armed = Some(focus.rect);
|
||||
}
|
||||
_ => {
|
||||
self.pad_pointer_to(event_loop, focus.center());
|
||||
self.pad_pointer_button(event_loop, false, true);
|
||||
@@ -718,9 +761,11 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
fn pad_focus_on(&mut self, event_loop: &dyn EventLoopCtl, target: Target) {
|
||||
self.pad.menu.shortcut_focus = None;
|
||||
let left = self.pad.menu.focus.replace(target);
|
||||
if left.is_some_and(|l| l.rect != target.rect) {
|
||||
self.clear_screen_click_focus();
|
||||
self.pad.menu.armed = None;
|
||||
}
|
||||
self.pad.menu.pending = None;
|
||||
self.pad.menu.nudge = None;
|
||||
@@ -744,6 +789,9 @@ impl Game<'_> {
|
||||
let Some(focus) = self.pad.menu.focus else {
|
||||
return;
|
||||
};
|
||||
if self.pad.menu.shortcut_focus.take() == Some(focus.rect) {
|
||||
return;
|
||||
}
|
||||
let remembered = &mut self.pad.menu.remembered;
|
||||
if remembered.len() > 32 && !remembered.contains_key(&screen) {
|
||||
remembered.clear();
|
||||
@@ -1107,6 +1155,9 @@ impl Game<'_> {
|
||||
"focus": self.pad.menu.focus.as_ref().map(target),
|
||||
"keyboard": self.pad.menu.osk.is_some(),
|
||||
"targets": self.pad_targets().iter().map(target).collect::<Vec<_>>(),
|
||||
"actions": self.pad_actions_status(),
|
||||
"glyphs": self.pad_glyphs_status(),
|
||||
"undeclared": self.pad_undeclared_buttons(),
|
||||
"screen_key": format!("{:016x}", self.key_screen()),
|
||||
"remembered": self
|
||||
.pad
|
||||
@@ -1598,6 +1649,8 @@ impl Game<'_> {
|
||||
/// The pad's cursor (pointer mode) or focus highlight (focus mode), and
|
||||
/// the on-screen keyboard, over everything else.
|
||||
pub(super) fn draw_pad_cursor(&self, batch: &mut HudBatch) {
|
||||
widgets::set_action_glyphs(self.pad_action_glyphs_on());
|
||||
widgets::set_action_screen(self.key_screen());
|
||||
if !self.pad_in_menu() {
|
||||
return;
|
||||
}
|
||||
@@ -1624,8 +1677,10 @@ impl Game<'_> {
|
||||
&& let Some(focus) = self.pad.menu.focus
|
||||
{
|
||||
self.draw_pad_focus(batch, focus);
|
||||
self.draw_pad_action_glyphs(batch);
|
||||
return;
|
||||
}
|
||||
self.draw_pad_action_glyphs(batch);
|
||||
// A crosshair centred on the point it clicks: inventories, and a
|
||||
// screen with nothing to focus.
|
||||
use horizon_client::pad_cursor;
|
||||
@@ -1686,6 +1741,21 @@ mod tests {
|
||||
assert!(!osk_auto_opens(false, false, true, 5, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn armed_button_clicks_only_while_still_focused() {
|
||||
let rect = [10.0, 20.0, 100.0, 20.0];
|
||||
let kind = Kind::Button;
|
||||
let focus = Some(Target { rect, kind });
|
||||
assert_eq!(armed_click(Some(rect), focus), Some((60.0, 30.0)));
|
||||
let moved = Some(Target {
|
||||
rect: [10.0, 44.0, 100.0, 20.0],
|
||||
kind,
|
||||
});
|
||||
assert_eq!(armed_click(Some(rect), moved), None);
|
||||
assert_eq!(armed_click(Some(rect), None), None);
|
||||
assert_eq!(armed_click(None, focus), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_focus_leaves_nothing_focused() {
|
||||
let mut menu = MenuCursor {
|
||||
|
||||
@@ -498,7 +498,10 @@ impl Game<'_> {
|
||||
m.previous_limb_speed = m.limb_speed;
|
||||
m.limb_speed += ((walked * 4.0).min(1.0) - m.limb_speed) * 0.4;
|
||||
m.limb_position += m.limb_speed;
|
||||
m.age = m.age.wrapping_add(1);
|
||||
// Its idle swing holds with the world under `HORIZON_STILL`.
|
||||
if self.clock.runs_normally() || !self.clock.still() {
|
||||
m.age = m.age.wrapping_add(1);
|
||||
}
|
||||
m.anim.attack_anim_o = attack.0;
|
||||
m.anim.attack_anim = attack.1;
|
||||
m.extra.set_main_hand_stack(main);
|
||||
|
||||
@@ -171,8 +171,9 @@ fn hurt_sound_for(
|
||||
fn entity_sound_state(
|
||||
id: i32,
|
||||
player: Option<(i32, [f64; 3], bool)>,
|
||||
mobs: &[Mob],
|
||||
mob: Option<horizon_client::sound_engine::SoundEntityState>,
|
||||
tracked: &HashMap<i32, Tracked>,
|
||||
dragon: Option<(i32, [f64; 3])>,
|
||||
) -> Option<horizon_client::sound_engine::SoundEntityState> {
|
||||
use horizon_client::sound_engine::SoundEntityState;
|
||||
if let Some((player_id, position, silent)) = player
|
||||
@@ -184,18 +185,20 @@ fn entity_sound_state(
|
||||
removed: false,
|
||||
});
|
||||
}
|
||||
if let Some(m) = mobs.iter().find(|m| m.id as i32 == id) {
|
||||
return Some(SoundEntityState {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
silent: m.silent,
|
||||
removed: m.removed,
|
||||
});
|
||||
let mut state = mob.or_else(|| {
|
||||
tracked.get(&id).map(|t| SoundEntityState {
|
||||
position: t.position,
|
||||
silent: t.silent,
|
||||
removed: false,
|
||||
})
|
||||
})?;
|
||||
// The dragon's Mob is metadata storage; only Dragon follows its movement.
|
||||
if let Some((dragon_id, position)) = dragon
|
||||
&& dragon_id == id
|
||||
{
|
||||
state.position = position;
|
||||
}
|
||||
tracked.get(&id).map(|t| SoundEntityState {
|
||||
position: t.position,
|
||||
silent: t.silent,
|
||||
removed: false,
|
||||
})
|
||||
Some(state)
|
||||
}
|
||||
|
||||
/// `ClientboundOpenScreenPacket.title` as a container screen draws it: the
|
||||
@@ -489,6 +492,9 @@ pub(super) struct Remote {
|
||||
pub(super) builtin: Arc<Builtin>,
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
/// The game's enchantments and the server's that it lacks (a data
|
||||
/// pack's), from their synced data; `None` when the server has none.
|
||||
pub(super) enchantments: Option<Arc<horizon_client::enchant::Enchantments>>,
|
||||
pub(super) joined: bool,
|
||||
/// `ServerReconfigScreen` is up since this tick.
|
||||
pub(super) reconfiguring: Option<u64>,
|
||||
@@ -776,7 +782,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn remote_sound_entity_state(
|
||||
&self,
|
||||
&mut self,
|
||||
id: i32,
|
||||
) -> Option<horizon_client::sound_engine::SoundEntityState> {
|
||||
let r = self.remote.as_ref()?;
|
||||
@@ -787,9 +793,29 @@ impl Game<'_> {
|
||||
r.local_entity_silent,
|
||||
)
|
||||
});
|
||||
let mut state = entity_sound_state(id, player, &self.mobs, &r.entities)?;
|
||||
// Vehicles, projectiles and other simulated objects may have moved since
|
||||
// the last tracking packet, just as the mob interpolation has.
|
||||
// The local player is not in mobs, so avoid a guaranteed index miss.
|
||||
if player.is_some_and(|p| p.0 == id) {
|
||||
return entity_sound_state(id, player, None, &r.entities, None);
|
||||
}
|
||||
let mob = self.mob_index.get(&self.mobs, id as u32).map(|m| {
|
||||
horizon_client::sound_engine::SoundEntityState {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
silent: m.silent,
|
||||
removed: m.removed,
|
||||
}
|
||||
});
|
||||
let dragon = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.map(|d| (d.id as i32, [d.position.x, d.position.y, d.position.z]));
|
||||
let mut state = entity_sound_state(id, player, mob, &r.entities, dragon)?;
|
||||
// Mob states already use the live position. In particular,
|
||||
// level_entity would find the dragon's stale Mob before its Dragon.
|
||||
if mob.is_some() || dragon.is_some_and(|d| d.0 == id) {
|
||||
return Some(state);
|
||||
}
|
||||
// Vehicles, projectiles and other simulated objects may have moved
|
||||
// since the last tracking packet.
|
||||
if let Some(position) = self
|
||||
.level_entity(id)
|
||||
.and_then(|target| self.target_position(target))
|
||||
@@ -1044,6 +1070,7 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| "Connecting to the server...".into()),
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
enchantments: None,
|
||||
joined: false,
|
||||
reconfiguring: None,
|
||||
logged_in: false,
|
||||
@@ -1456,7 +1483,7 @@ impl Game<'_> {
|
||||
content,
|
||||
signature,
|
||||
trust.tag(),
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
} else {
|
||||
@@ -1468,7 +1495,7 @@ impl Game<'_> {
|
||||
content,
|
||||
indicator,
|
||||
source,
|
||||
self.ticks,
|
||||
self.visual_ticks(),
|
||||
focused,
|
||||
);
|
||||
}
|
||||
@@ -1594,9 +1621,10 @@ impl Game<'_> {
|
||||
r.chat_queue
|
||||
.retain(|chat| chat.signature.as_ref() != Some(&signature));
|
||||
if r.chat_queue.len() == queued {
|
||||
let ticks = self.visual_ticks();
|
||||
self.ui
|
||||
.chat
|
||||
.delete_message(&self.gui.atlas, &signature, self.ticks);
|
||||
.delete_message(&self.gui.atlas, &signature, ticks);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2151,7 +2179,25 @@ impl Game<'_> {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let entries: Vec<(&str, serde_json::Value)> = registries
|
||||
.registries
|
||||
.get("minecraft:enchantment")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(name, data)| {
|
||||
Some((
|
||||
name.as_str(),
|
||||
horizon_client::scoreboard::tag_to_json(data.as_ref()?),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let enchantments = self
|
||||
.stations()
|
||||
.enchantments
|
||||
.with_entries(entries.iter().map(|(n, v)| (*n, v)))
|
||||
.map(Arc::new);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.enchantments = enchantments;
|
||||
let bits = ceil_log2(biomes.len());
|
||||
let column = r.chunk_format.layout();
|
||||
r.chunk_format =
|
||||
@@ -3205,13 +3251,9 @@ impl Game<'_> {
|
||||
false,
|
||||
horizon_client::level_loading::Reason::Other,
|
||||
);
|
||||
let info = self
|
||||
.settings
|
||||
.client_information(client_view_distance(self.settings.render_distance));
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.sent_info = info.clone();
|
||||
}
|
||||
self.send(S::ClientInformation(info));
|
||||
// No ClientInformation here: the configuration phase sent it,
|
||||
// and ClientPacketListener.broadcastClientInformation sends it
|
||||
// again only when an option changes it.
|
||||
self.remote_chat_login();
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
@@ -4241,11 +4283,11 @@ impl Game<'_> {
|
||||
if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) {
|
||||
self.poof_particles(p.id as u32);
|
||||
}
|
||||
// Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and
|
||||
// 7, Animal and Allay 18: the particles about the mob.
|
||||
if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18 | 40 | 41) {
|
||||
self.entity_event_particles(p.id as u32, p.event);
|
||||
}
|
||||
// The particle bursts of Villager 12 to 14 and 42, TamableAnimal
|
||||
// and AbstractHorse 6 and 7, Animal and Allay 18, Ocelot 40 and
|
||||
// 41, Dolphin 38: the table in `entity_event_burst` is the one
|
||||
// list of them; any other event draws nothing here.
|
||||
self.entity_event_particles(p.id as u32, p.event);
|
||||
// Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where
|
||||
// the projectile is (the server removes it right after).
|
||||
if p.event == 3 {
|
||||
@@ -4272,9 +4314,10 @@ impl Game<'_> {
|
||||
if p.event == super::combat::DROWN_EVENT {
|
||||
self.remote_drown(p.id);
|
||||
}
|
||||
// Entity event 53: the honey slide particles of a player
|
||||
// sliding down a honey block (HoneyBlock.showSlideParticles).
|
||||
if p.event == 53 {
|
||||
// Entity events 53 and 54: honey crumbs while sliding, and the
|
||||
// ten from a landing (`HoneyBlock.showSlideParticles` /
|
||||
// `showJumpParticles`).
|
||||
if horizon_client::landing::honey_particle_count(p.event).is_some() {
|
||||
let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
self.player
|
||||
.as_ref()
|
||||
@@ -4286,7 +4329,7 @@ impl Game<'_> {
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z])
|
||||
};
|
||||
if let Some(at) = at {
|
||||
self.honey_slide_particles(at);
|
||||
self.honey_entity_particles(at, p.event);
|
||||
}
|
||||
}
|
||||
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
|
||||
@@ -5633,7 +5676,10 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
|
||||
// ItemEntity's constructor draws bobOffs from the item's own random.
|
||||
let bob_offset = horizon_client::mobs::entity_random(id as u32, at).next_float()
|
||||
* std::f32::consts::PI
|
||||
* 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
@@ -5929,7 +5975,7 @@ impl Game<'_> {
|
||||
self.tick_chat_session();
|
||||
// Minecraft.tick sends this even when the player cannot tick yet,
|
||||
// including while waiting for chunks. Keep it outside early returns.
|
||||
if self.remote.as_ref().is_some_and(|r| r.joined) {
|
||||
if self.remote.as_ref().is_some_and(Remote::has_level) {
|
||||
self.send(S::ClientTickEnd(Empty));
|
||||
}
|
||||
Ok(())
|
||||
@@ -5939,7 +5985,8 @@ impl Game<'_> {
|
||||
self.ticks += 1;
|
||||
self.repro_tick();
|
||||
// Hud.tick ends with ChatComponent.tick.
|
||||
self.ui.chat.tick(&self.gui.atlas, self.ticks);
|
||||
let chat_ticks = self.visual_ticks();
|
||||
self.ui.chat.tick(&self.gui.atlas, chat_ticks);
|
||||
self.tick_sign_editor();
|
||||
self.track_experience_display();
|
||||
self.tick_enchanting_book();
|
||||
@@ -5961,7 +6008,13 @@ impl Game<'_> {
|
||||
// the time only while the level runs normally.
|
||||
let running = self.clock.world_runs();
|
||||
self.boss_overlay.tick_darken(running);
|
||||
self.tick_item_activation();
|
||||
// Under `HORIZON_STILL` the frame's countdowns (this one, the hotbar's pop, the HUD's
|
||||
// below) wait for the world, as the border's size change does in any frozen level: a
|
||||
// frozen client keeps ticking, as many ticks as the run took.
|
||||
let hold = !running && self.clock.still();
|
||||
if !hold {
|
||||
self.tick_item_activation();
|
||||
}
|
||||
if running {
|
||||
self.world.game_time += 1;
|
||||
self.remote_clock_tick();
|
||||
@@ -5986,11 +6039,15 @@ impl Game<'_> {
|
||||
// trying again a tick before the server's entry ends.
|
||||
self.cooldowns.tick();
|
||||
// Inventory.tick's ItemStack.inventoryTick: the pop time runs down.
|
||||
for pop in &mut self.hotbar_pop {
|
||||
*pop = pop.saturating_sub(1);
|
||||
if !hold {
|
||||
for pop in &mut self.hotbar_pop {
|
||||
*pop = pop.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
// ClientLevel.tick: the border's size change, while the level runs.
|
||||
if running {
|
||||
self.border.tick();
|
||||
}
|
||||
// ClientLevel.tick: the border's size change.
|
||||
self.border.tick();
|
||||
self.player_border();
|
||||
self.tick_remote_entities(running);
|
||||
self.tick_random_textures();
|
||||
@@ -6018,12 +6075,22 @@ impl Game<'_> {
|
||||
if running {
|
||||
self.tick_particles();
|
||||
}
|
||||
self.tick_client_ambient();
|
||||
self.tick_client_effects();
|
||||
// Under `HORIZON_STILL` they wait for the world too: endermen's and blazes' particles
|
||||
// draw on the particles' random, and effects count down, on every tick a frozen
|
||||
// client runs, as many as the run took to get there.
|
||||
if !hold {
|
||||
self.tick_client_ambient();
|
||||
self.tick_client_effects();
|
||||
}
|
||||
// Player.tick's sleep counter (the darkening and the wake-up fade).
|
||||
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
|
||||
// Gui.tick: the titles, the action bar and the held item's name.
|
||||
self.tick_hud();
|
||||
if !hold {
|
||||
self.tick_hud();
|
||||
} else {
|
||||
// Only its countdowns wait: lying down still opens the bed's chat, waking closes it.
|
||||
self.tick_in_bed_chat();
|
||||
}
|
||||
self.tick_subtitles();
|
||||
self.tick_client_load();
|
||||
let entity_collisions = self.player_entity_collisions();
|
||||
@@ -6291,9 +6358,10 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
|
||||
}
|
||||
// LivingEntity.causeFallDamage on the client's own level: the
|
||||
// fall sound is heard here (LocalPlayer.playSound); the
|
||||
// server's copy skips this player (Player.playSound).
|
||||
// LivingEntity.causeFallDamage and HoneyBlock.fallOn on the
|
||||
// client's own level: the fall and honey sounds are heard here
|
||||
// (LocalPlayer.playSound); the server's copy skips this player
|
||||
// (Player.playSound).
|
||||
horizon_client::motion::MotionEvent::Fall(
|
||||
horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
@@ -6302,7 +6370,10 @@ impl Game<'_> {
|
||||
..
|
||||
},
|
||||
) => {
|
||||
if let Some(name) = sound.player_fall_sound() {
|
||||
if let Some(name) = sound
|
||||
.player_fall_sound()
|
||||
.or_else(|| sound.honey_landing_sound())
|
||||
{
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
}
|
||||
@@ -6976,6 +7047,8 @@ impl Game<'_> {
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
|
||||
}
|
||||
// ClientLevel.tick: the sky flash fades in a frozen level too.
|
||||
self.flash = self.flash.saturating_sub(1);
|
||||
if running {
|
||||
self.guardian_bubbles();
|
||||
// The dragon: LivingEntity's interpolation, inside its aiStep.
|
||||
@@ -7039,8 +7112,7 @@ impl Game<'_> {
|
||||
},
|
||||
);
|
||||
self.tick_remote_projectiles(&mut step);
|
||||
// LightningBolt.tick on a client, and the sky flash fading.
|
||||
self.flash = self.flash.saturating_sub(1);
|
||||
// LightningBolt.tick on a client.
|
||||
self.tick_bolts();
|
||||
}
|
||||
lerps.retain(|_, l| l.steps > 0);
|
||||
@@ -7656,7 +7728,11 @@ impl Game<'_> {
|
||||
.is_some_and(|r| r.level_players.contains_key(&id));
|
||||
if !player && self.remote_mob_takes(i, hand) {
|
||||
self.remote_feed_particles(i, hand);
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
// As for the objects above, only SUCCESS swings the hand: a
|
||||
// CONSUME ends the click without one.
|
||||
if self.remote_mob_swings(i, hand) {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8531,8 +8607,8 @@ impl Game<'_> {
|
||||
}
|
||||
// TridentItem.use: not about to break, Riptide only in water or rain.
|
||||
"minecraft:trident" => {
|
||||
self.use_trident(held);
|
||||
return true;
|
||||
// FAIL: no dip, and the off hand gets its turn.
|
||||
return self.use_trident(held);
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
@@ -10635,6 +10711,13 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// `Minecraft.level != null`: from `handleLogin` until the next
|
||||
/// `handleConfigurationStart` (`clearClientLevel`). Minecraft.tick sends
|
||||
/// CLIENT_TICK_END only then, so a client waiting for the server's Login
|
||||
/// (a busy server takes seconds to place a new player) sends nothing.
|
||||
pub(super) fn has_level(&self) -> bool {
|
||||
client_has_level(self.logged_in, self.reconfiguring)
|
||||
}
|
||||
/// The epoch of this client level's entity handles (`native_epoch`).
|
||||
pub(super) fn native_epoch(&self) -> Option<horizon_client::mod_api::NativeWorldEpoch> {
|
||||
self.native_epoch
|
||||
@@ -10810,11 +10893,18 @@ fn hold_server_fire(fire_ticks: i32, server_on_fire: bool) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Remote::has_level`]: logged in (a Login arrived) and no reconfiguration
|
||||
/// since (`reconfiguring` is set by StartConfiguration, cleared by the next
|
||||
/// Login).
|
||||
fn client_has_level(logged_in: bool, reconfiguring: Option<u64>) -> bool {
|
||||
logged_in && reconfiguring.is_none()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
boat_interact_passes, hold_server_fire, item_frame_interact_passes, keep_client_state,
|
||||
remote_object_swings, remote_use_swings, synced_local_use,
|
||||
boat_interact_passes, client_has_level, hold_server_fire, item_frame_interact_passes,
|
||||
keep_client_state, remote_object_swings, remote_use_swings, synced_local_use,
|
||||
};
|
||||
use horizon_client::{
|
||||
containers::{Beacon, BlockEntity},
|
||||
@@ -10955,6 +11045,16 @@ mod tests {
|
||||
assert!(!remote_use_swings(&stack("minecraft:apple"), true));
|
||||
}
|
||||
|
||||
/// Minecraft.tick: CLIENT_TICK_END only while a level exists. None before
|
||||
/// the first Login (configuration has finished, the server has not placed
|
||||
/// the player yet), and none from StartConfiguration until the next Login.
|
||||
#[test]
|
||||
fn tick_ends_need_the_level_the_login_makes() {
|
||||
assert!(!client_has_level(false, None), "joined, waiting for Login");
|
||||
assert!(client_has_level(true, None), "after Login");
|
||||
assert!(!client_has_level(true, Some(40)), "reconfiguring");
|
||||
}
|
||||
|
||||
/// The server's on-fire flag keeps the predicted countdown (which
|
||||
/// `fluid_base_tick` runs down) from ending the fire; a clear flag leaves
|
||||
/// the window's own contact fire alone.
|
||||
@@ -11393,28 +11493,28 @@ mod tests {
|
||||
);
|
||||
let player = Some((2, [0.5, 70.0, 0.5], false));
|
||||
assert_eq!(
|
||||
super::entity_sound_state(2, player, &[], &tracked)
|
||||
super::entity_sound_state(2, player, None, &tracked, None)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
Some([0.5, 70.0, 0.5]),
|
||||
"the local player is named by Remote.entity_id"
|
||||
);
|
||||
assert_eq!(
|
||||
super::entity_sound_state(7, player, &[], &tracked)
|
||||
super::entity_sound_state(7, player, None, &tracked, None)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
Some([8.5, 64.0, -3.5]),
|
||||
"another entity the server spawned"
|
||||
);
|
||||
assert_eq!(
|
||||
super::entity_sound_state(9, player, &[], &tracked)
|
||||
super::entity_sound_state(9, player, None, &tracked, None)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
None,
|
||||
"getEntity is null: handleSoundEntityEvent returns"
|
||||
);
|
||||
assert_eq!(
|
||||
super::entity_sound_state(2, None, &[], &tracked)
|
||||
super::entity_sound_state(2, None, None, &tracked, None)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
None,
|
||||
@@ -11433,15 +11533,40 @@ mod tests {
|
||||
silent: true,
|
||||
},
|
||||
);
|
||||
let state = super::entity_sound_state(7, None, &[], &tracked).unwrap();
|
||||
let state = super::entity_sound_state(7, None, None, &tracked, None).unwrap();
|
||||
assert!(state.silent);
|
||||
assert!(!state.removed);
|
||||
assert_eq!(state.position, [8.5, 64.0, -3.5]);
|
||||
let player =
|
||||
super::entity_sound_state(2, Some((2, [1.0, 2.0, 3.0], true)), &[], &tracked).unwrap();
|
||||
super::entity_sound_state(2, Some((2, [1.0, 2.0, 3.0], true)), None, &tracked, None)
|
||||
.unwrap();
|
||||
assert!(player.silent);
|
||||
assert!(super::entity_sound_state(9, None, &[], &tracked).is_none());
|
||||
assert!(super::entity_sound_state(9, None, None, &tracked, None).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn a_dragon_sound_uses_live_movement_without_losing_mob_metadata() {
|
||||
use horizon_client::sound_engine::SoundEntityState;
|
||||
let stale = SoundEntityState {
|
||||
position: [0.0, 64.0, 0.0],
|
||||
silent: true,
|
||||
removed: false,
|
||||
};
|
||||
let tracked = std::collections::HashMap::new();
|
||||
for position in [[24.0, 80.0, -16.0], [25.5, 81.0, -18.0]] {
|
||||
let state =
|
||||
super::entity_sound_state(7, None, Some(stale), &tracked, Some((7, position)))
|
||||
.unwrap();
|
||||
assert_eq!(state.position, position);
|
||||
assert!(state.silent);
|
||||
assert!(!state.removed);
|
||||
}
|
||||
let unrelated =
|
||||
super::entity_sound_state(7, None, Some(stale), &tracked, Some((8, [99.0; 3])))
|
||||
.unwrap();
|
||||
assert_eq!(unrelated.position, stale.position);
|
||||
assert!(super::entity_sound_state(7, None, None, &tracked, Some((7, [99.0; 3]))).is_none());
|
||||
}
|
||||
|
||||
/// Q over a slot of the inventory on a server (#3357): the predicted THROW
|
||||
/// drops the stack, so the player's hand swings, as `LivingEntity.drop`
|
||||
/// does for `Prediction.PREDICTED`; a plain pick up drops nothing.
|
||||
|
||||
@@ -412,8 +412,9 @@ impl Game<'_> {
|
||||
&[],
|
||||
),
|
||||
];
|
||||
self.command_prompt_layout(prompt, (w, h))
|
||||
.draw(batch, atlas, self.cursor, &labels);
|
||||
let layout = self.command_prompt_layout(prompt, (w, h));
|
||||
layout.declare_no(1);
|
||||
layout.draw(batch, atlas, self.cursor, &labels);
|
||||
}
|
||||
|
||||
/// The confirmation's answer; on no, what was behind it is back.
|
||||
|
||||
@@ -148,6 +148,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
(Species::Mooshroom, 18, Value::Int(variant)) => {
|
||||
m.extra.variant = (*variant == 1) as u8
|
||||
}
|
||||
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (the first entry
|
||||
// after AgeableMob's two; the variant and DATA_SUFFOCATING are 19).
|
||||
// ItemBasedSteering.onSynced: boosting starts, boostTime is 0, and
|
||||
// the total is this int (140 to 980 from boost()).
|
||||
(Species::Pig | Species::Strider, 18, Value::Int(total)) => {
|
||||
m.extra.counter = 0;
|
||||
m.extra.counter2 = *total;
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING: the dance pose.
|
||||
(Species::Piglin, 19, Value::Bool(dancing)) => m.extra.scratch[0] = i32::from(*dancing),
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
@@ -1430,4 +1438,66 @@ mod tests {
|
||||
// Last fuse tick: still primed at 0 until the server removes it.
|
||||
assert_eq!(horizon_client::mobs::sulfur_cube::fuse(&m), 0);
|
||||
}
|
||||
|
||||
/// `Pig.onSyncedDataUpdated` and `Strider.onSyncedDataUpdated`: a synced
|
||||
/// `DATA_BOOST_TIME` (index 18, after AgeableMob's two) calls
|
||||
/// `ItemBasedSteering.onSynced`. Boosting starts, `boostTime` is 0, and
|
||||
/// the total is the int (`boost()`'s minimum is 140). The next
|
||||
/// `tickBoost` then `boostFactor` is `1 + 1.15 * sin(boostTime / total * PI)`.
|
||||
#[test]
|
||||
fn a_synced_boost_starts_the_client_timer() {
|
||||
use horizon_client::mobs::MobTypes;
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
use std::{fs, sync::Arc};
|
||||
|
||||
let root = std::env::temp_dir().join(format!("horizon-boost-sync-{}", std::process::id()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
fs::write(
|
||||
root.join("entity-types.json"),
|
||||
r#"{"minecraft:pig": {}, "minecraft:strider": {}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("entity-models.json"),
|
||||
r#"{"minecraft:pig#main": {}, "minecraft:strider#main": {}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut types = MobTypes::load(&root).unwrap();
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
|
||||
// ItemBasedSteering: nextInt(841) + 140, and 1.15 in boostFactor.
|
||||
const TOTAL: i32 = 140;
|
||||
const FACTOR: f32 = 1.15;
|
||||
for (kind, steer) in [
|
||||
("minecraft:pig", 0.225_f64),
|
||||
("minecraft:strider", 0.55_f64),
|
||||
] {
|
||||
let t = Arc::new(types.types.remove(kind).unwrap());
|
||||
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
m.attrs.speed = 0.25;
|
||||
m.extra.saddled = true;
|
||||
m.extra.counter = 5;
|
||||
m.extra.counter2 = 3;
|
||||
// The next entry (pig variant, strider suffocating) is not the boost.
|
||||
apply(&mut m, &[(19, s::INT, Value::Int(TOTAL))]);
|
||||
assert_eq!((m.extra.counter, m.extra.counter2), (5, 3), "{kind}");
|
||||
apply(&mut m, &[(18, s::INT, Value::Int(TOTAL))]);
|
||||
assert_eq!(m.extra.counter, 0, "{kind}: onSynced sets boostTime to 0");
|
||||
assert_eq!(m.extra.counter2, TOTAL, "{kind}: the synced total");
|
||||
m.rider = Some(equine::Rider {
|
||||
steer: true,
|
||||
..Default::default()
|
||||
});
|
||||
equine::ridden_step(&mut m);
|
||||
// tickBoost post-increments, then boostFactor reads boostTime 1.
|
||||
let boost = 1.0 + FACTOR * (1.0_f32 / TOTAL as f32 * std::f32::consts::PI).msin();
|
||||
let expected = (0.25_f64 * steer) as f32 * boost;
|
||||
assert_eq!(m.extra.counter, 1, "{kind}");
|
||||
assert!(
|
||||
(m.ai.speed - expected).abs() < 1.0e-5,
|
||||
"{kind}: {} != {expected}",
|
||||
m.ai.speed
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,23 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Whether that answer is `InteractionResult.SUCCESS`, the only result that
|
||||
/// swings the hand: `CONSUME` ends the click without one. An adult animal's
|
||||
/// food (`Animal.mobInteract`), a zombie villager's golden apple and a
|
||||
/// sheep's shears (the server alone checks `readyForShearing`) answer
|
||||
/// CONSUME on a client, and a horse's or panda's food SUCCESS_SERVER
|
||||
/// (#9437). A tame wolf, cat or parrot sitting down is SUCCESS.
|
||||
pub(super) fn remote_mob_swings(&self, i: usize, hand: Hand) -> bool {
|
||||
let Some(m) = self.mobs.get(i).filter(|m| m.alive()) else {
|
||||
return false;
|
||||
};
|
||||
let stack = match hand {
|
||||
Hand::Main => self.stack_in(self.selected),
|
||||
Hand::Off => self.offhand.as_ref(),
|
||||
};
|
||||
let item = stack.map_or("", |s| &*s.item);
|
||||
mob_swings(m, item)
|
||||
}
|
||||
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
|
||||
/// client's copy: a baby fed something that ages it draws one happy
|
||||
/// villager particle. The server draws it on its own level only, so no
|
||||
@@ -208,6 +225,48 @@ pub(super) fn ages_on_food(m: &Mob, item: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Game::remote_mob_swings`] for mob `m` and the held `item`.
|
||||
fn mob_swings(m: &Mob, item: &str) -> bool {
|
||||
let food = m.kind.food.iter().any(|f| f.as_str() == item);
|
||||
let hurt = m.health < m.attrs.max_health;
|
||||
match m.species() {
|
||||
// AbstractHorse.fedFood (Camel's, Llama's too): SUCCESS_SERVER,
|
||||
// which swings on the server's side only.
|
||||
s if food
|
||||
&& (equine::is_equine(s)
|
||||
|| llama::is_llama(s)
|
||||
|| matches!(s, Species::Camel | Species::CamelHusk)) =>
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Panda.mobInteract: its bamboo answers SUCCESS_SERVER.
|
||||
Species::Panda if food => return false,
|
||||
// Cat.mobInteract: food tames an untamed cat, and heals a hurt
|
||||
// tame one: SUCCESS before Animal.mobInteract is reached.
|
||||
Species::Cat if food && (!m.extra.tame || hurt) => return true,
|
||||
// Wolf.mobInteract: food heals a hurt tame wolf (SUCCESS).
|
||||
Species::Wolf if food && m.extra.tame && hurt => return true,
|
||||
_ => {}
|
||||
}
|
||||
// Animal.mobInteract: a baby's food is SUCCESS, since it ages up; an
|
||||
// adult's is CONSUME, as only the server knows whether it can breed.
|
||||
if m.kind.flags.animal
|
||||
&& !matches!(m.species(), Species::Nautilus | Species::ZombieNautilus)
|
||||
&& food
|
||||
{
|
||||
return m.is_baby() && !m.extra.age_locked;
|
||||
}
|
||||
// A tame wolf, cat or parrot sits or stands up: SUCCESS
|
||||
// (TamableAnimal's subclasses), so it swings.
|
||||
let consumes = match m.species() {
|
||||
// ZombieVillager.mobInteract: a golden apple cures it on the server.
|
||||
Species::ZombieVillager => item == "minecraft:golden_apple",
|
||||
// Sheep.mobInteract: shears, sheared or not.
|
||||
Species::Sheep => item == "minecraft:shears",
|
||||
_ => false,
|
||||
};
|
||||
!consumes
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -230,6 +289,50 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
/// Tamables' and horses' own `mobInteract` answer before `Animal.mobInteract`'s
|
||||
/// CONSUME (#9450): sitting down and healing are SUCCESS, a horse's food
|
||||
/// SUCCESS_SERVER.
|
||||
#[test]
|
||||
fn only_a_client_success_swings_the_hand() {
|
||||
let Some(mut wolf) = mob("minecraft:wolf") else {
|
||||
return;
|
||||
};
|
||||
wolf.extra.tame = true;
|
||||
assert!(
|
||||
mob_swings(&wolf, ""),
|
||||
"a tame wolf sits: SUCCESS.withoutItem"
|
||||
);
|
||||
assert!(
|
||||
!mob_swings(&wolf, "minecraft:beef"),
|
||||
"full health: Animal's CONSUME"
|
||||
);
|
||||
wolf.health = wolf.attrs.max_health - 1.0;
|
||||
assert!(
|
||||
mob_swings(&wolf, "minecraft:beef"),
|
||||
"hurt: Wolf.feed is SUCCESS"
|
||||
);
|
||||
let mut cat = mob("minecraft:cat").unwrap();
|
||||
assert!(mob_swings(&cat, "minecraft:cod"), "taming: SUCCESS");
|
||||
cat.extra.tame = true;
|
||||
assert!(
|
||||
!mob_swings(&cat, "minecraft:cod"),
|
||||
"tame, full health: CONSUME"
|
||||
);
|
||||
assert!(mob_swings(&cat, ""), "a tame cat sits: SUCCESS");
|
||||
let mut parrot = mob("minecraft:parrot").unwrap();
|
||||
parrot.extra.tame = true;
|
||||
assert!(mob_swings(&parrot, ""), "a tame parrot sits: SUCCESS");
|
||||
let horse = mob("minecraft:horse").unwrap();
|
||||
assert!(
|
||||
!mob_swings(&horse, "minecraft:apple"),
|
||||
"AbstractHorse.fedFood: SUCCESS_SERVER"
|
||||
);
|
||||
let cow = mob("minecraft:cow").unwrap();
|
||||
assert!(!mob_swings(&cow, "minecraft:wheat"), "adult: CONSUME");
|
||||
let sheep = mob("minecraft:sheep").unwrap();
|
||||
assert!(!mob_swings(&sheep, "minecraft:shears"), "Sheep: CONSUME");
|
||||
}
|
||||
|
||||
/// `AbstractNautilus.mobInteract` (#8595): an untamed adult takes its taming
|
||||
/// items only, and a tame adult is mounted by any click.
|
||||
#[test]
|
||||
|
||||
@@ -112,6 +112,7 @@ impl Game<'_> {
|
||||
fixed: false,
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
custom_name: None,
|
||||
});
|
||||
} else if kind == "minecraft:cushion" {
|
||||
// BlockAttachedEntity.pos: `BlockPos.containing` of the spawn.
|
||||
|
||||
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user